From 0546b44507d8ed228b8423b91c9dc251aed69bb7 Mon Sep 17 00:00:00 2001 From: Stefano Bertelli Date: Mon, 30 Mar 2026 16:53:46 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20WireGUI=20implementation=20?= =?UTF-8?q?=E2=80=94=20full=20VPN=20management=20platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete Python/NiceGUI rewrite of the Wirezone (Elixir/Phoenix) VPN management platform. All 10 implementation phases delivered. Core stack: - NiceGUI reactive UI with SQLModel ORM on PostgreSQL (asyncpg) - Alembic migrations, Valkey/Redis cache, pydantic-settings config - WireGuard management via subprocess (wg/ip/nft CLIs) - 164 tests passing, 35% code coverage Features: - User/device/rule CRUD with admin and unprivileged roles - Full device config form with per-device WG overrides - WireGuard client config generation with QR codes - REST API (v0) with Bearer token auth for all resources - TOTP MFA with QR registration and challenge flow - OIDC SSO with authlib (provider registry, auto-create users) - Magic link passwordless sign-in via email - SAML SP-initiated SSO with IdP metadata parsing - WebAuthn/FIDO2 security key registration - nftables firewall with per-user chains and masquerade - Background tasks: WG stats polling, VPN session expiry, OIDC token refresh, WAN connectivity checks - Startup reconciliation (DB ↔ WireGuard state sync) - In-memory notification system with header badge - Admin UI: users, devices, rules, settings (3 tabs), diagnostics - Loguru logging with optional timestamped file output Deployment: - Multi-stage Dockerfile (python:3.13-slim) - Docker Compose prod stack (bridge networking, NET_ADMIN, nftables) - Forgejo CI: tests → semantic versioning → Docker registry push - Health endpoint at /api/health --- .dockerignore | 13 + .forgejo/workflows/release.yml | 211 ++ .gitignore | 6 + .python-version | 1 + CLAUDE.md | 124 + Dockerfile | 55 + README.md | 0 TODO.md | 196 ++ alembic.ini | 36 + alembic/env.py | 47 + alembic/script.py.mako | 27 + ...748_add_server_keypair_to_configuration.py | 33 + .../versions/647a4418cc8c_initial_schema.py | 171 ++ compose.prod.yml | 63 + compose.yml | 22 + pyproject.toml | 49 + tests/__init__.py | 0 tests/conftest.py | 65 + tests/test_account.py | 161 ++ tests/test_admin.py | 283 +++ tests/test_api.py | 86 + tests/test_api_routes.py | 325 +++ tests/test_auth.py | 98 + tests/test_auth_extended.py | 226 ++ tests/test_firewall.py | 40 + tests/test_integration_mfa.py | 239 ++ tests/test_integration_oidc.py | 309 +++ tests/test_magic_link.py | 58 + tests/test_mfa.py | 127 ++ tests/test_models.py | 168 ++ tests/test_notifications.py | 89 + tests/test_services.py | 124 + tests/test_services_extended.py | 203 ++ tests/test_tasks.py | 231 ++ tests/test_tasks_extended.py | 229 ++ tests/test_utils.py | 120 + uv.lock | 2016 +++++++++++++++++ wiregui/__init__.py | 0 wiregui/api/__init__.py | 0 wiregui/api/deps.py | 38 + wiregui/api/v0/__init__.py | 11 + wiregui/api/v0/configuration.py | 46 + wiregui/api/v0/devices.py | 119 + wiregui/api/v0/rules.py | 86 + wiregui/api/v0/users.py | 86 + wiregui/auth/__init__.py | 0 wiregui/auth/api_token.py | 42 + wiregui/auth/jwt.py | 26 + wiregui/auth/mfa.py | 31 + wiregui/auth/middleware.py | 20 + wiregui/auth/oidc.py | 59 + wiregui/auth/passwords.py | 9 + wiregui/auth/saml.py | 114 + wiregui/auth/seed.py | 61 + wiregui/auth/session.py | 22 + wiregui/auth/webauthn.py | 134 ++ wiregui/config.py | 55 + wiregui/db.py | 22 + wiregui/logging.py | 28 + wiregui/main.py | 95 + wiregui/models/__init__.py | 21 + wiregui/models/api_token.py | 24 + wiregui/models/configuration.py | 61 + wiregui/models/connectivity_check.py | 18 + wiregui/models/device.py | 52 + wiregui/models/mfa_method.py | 27 + wiregui/models/oidc_connection.py | 27 + wiregui/models/rule.py | 27 + wiregui/models/user.py | 41 + wiregui/pages/__init__.py | 0 wiregui/pages/account.py | 388 ++++ wiregui/pages/admin/__init__.py | 0 wiregui/pages/admin/devices.py | 350 +++ wiregui/pages/admin/diagnostics.py | 162 ++ wiregui/pages/admin/rules.py | 228 ++ wiregui/pages/admin/settings.py | 367 +++ wiregui/pages/admin/users.py | 236 ++ wiregui/pages/auth_magic.py | 91 + wiregui/pages/auth_oidc.py | 120 + wiregui/pages/auth_saml.py | 129 ++ wiregui/pages/devices.py | 463 ++++ wiregui/pages/home.py | 9 + wiregui/pages/layout.py | 48 + wiregui/pages/login.py | 82 + wiregui/pages/mfa_challenge.py | 93 + wiregui/redis.py | 9 + wiregui/schemas/__init__.py | 0 wiregui/schemas/configuration.py | 37 + wiregui/schemas/device.py | 50 + wiregui/schemas/rule.py | 31 + wiregui/schemas/user.py | 28 + wiregui/services/__init__.py | 0 wiregui/services/email.py | 51 + wiregui/services/events.py | 136 ++ wiregui/services/firewall.py | 191 ++ wiregui/services/notifications.py | 65 + wiregui/services/wireguard.py | 188 ++ wiregui/tasks/__init__.py | 28 + wiregui/tasks/connectivity.py | 60 + wiregui/tasks/oidc_refresh.py | 108 + wiregui/tasks/reconcile.py | 90 + wiregui/tasks/stats.py | 56 + wiregui/tasks/vpn_session.py | 74 + wiregui/utils/__init__.py | 0 wiregui/utils/crypto.py | 31 + wiregui/utils/network.py | 62 + wiregui/utils/server_key.py | 19 + wiregui/utils/time.py | 6 + wiregui/utils/wg_conf.py | 54 + 109 files changed, 11793 insertions(+) create mode 100644 .dockerignore create mode 100644 .forgejo/workflows/release.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 TODO.md create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/0741bc76e748_add_server_keypair_to_configuration.py create mode 100644 alembic/versions/647a4418cc8c_initial_schema.py create mode 100644 compose.prod.yml create mode 100644 compose.yml create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_account.py create mode 100644 tests/test_admin.py create mode 100644 tests/test_api.py create mode 100644 tests/test_api_routes.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_auth_extended.py create mode 100644 tests/test_firewall.py create mode 100644 tests/test_integration_mfa.py create mode 100644 tests/test_integration_oidc.py create mode 100644 tests/test_magic_link.py create mode 100644 tests/test_mfa.py create mode 100644 tests/test_models.py create mode 100644 tests/test_notifications.py create mode 100644 tests/test_services.py create mode 100644 tests/test_services_extended.py create mode 100644 tests/test_tasks.py create mode 100644 tests/test_tasks_extended.py create mode 100644 tests/test_utils.py create mode 100644 uv.lock create mode 100644 wiregui/__init__.py create mode 100644 wiregui/api/__init__.py create mode 100644 wiregui/api/deps.py create mode 100644 wiregui/api/v0/__init__.py create mode 100644 wiregui/api/v0/configuration.py create mode 100644 wiregui/api/v0/devices.py create mode 100644 wiregui/api/v0/rules.py create mode 100644 wiregui/api/v0/users.py create mode 100644 wiregui/auth/__init__.py create mode 100644 wiregui/auth/api_token.py create mode 100644 wiregui/auth/jwt.py create mode 100644 wiregui/auth/mfa.py create mode 100644 wiregui/auth/middleware.py create mode 100644 wiregui/auth/oidc.py create mode 100644 wiregui/auth/passwords.py create mode 100644 wiregui/auth/saml.py create mode 100644 wiregui/auth/seed.py create mode 100644 wiregui/auth/session.py create mode 100644 wiregui/auth/webauthn.py create mode 100644 wiregui/config.py create mode 100644 wiregui/db.py create mode 100644 wiregui/logging.py create mode 100644 wiregui/main.py create mode 100644 wiregui/models/__init__.py create mode 100644 wiregui/models/api_token.py create mode 100644 wiregui/models/configuration.py create mode 100644 wiregui/models/connectivity_check.py create mode 100644 wiregui/models/device.py create mode 100644 wiregui/models/mfa_method.py create mode 100644 wiregui/models/oidc_connection.py create mode 100644 wiregui/models/rule.py create mode 100644 wiregui/models/user.py create mode 100644 wiregui/pages/__init__.py create mode 100644 wiregui/pages/account.py create mode 100644 wiregui/pages/admin/__init__.py create mode 100644 wiregui/pages/admin/devices.py create mode 100644 wiregui/pages/admin/diagnostics.py create mode 100644 wiregui/pages/admin/rules.py create mode 100644 wiregui/pages/admin/settings.py create mode 100644 wiregui/pages/admin/users.py create mode 100644 wiregui/pages/auth_magic.py create mode 100644 wiregui/pages/auth_oidc.py create mode 100644 wiregui/pages/auth_saml.py create mode 100644 wiregui/pages/devices.py create mode 100644 wiregui/pages/home.py create mode 100644 wiregui/pages/layout.py create mode 100644 wiregui/pages/login.py create mode 100644 wiregui/pages/mfa_challenge.py create mode 100644 wiregui/redis.py create mode 100644 wiregui/schemas/__init__.py create mode 100644 wiregui/schemas/configuration.py create mode 100644 wiregui/schemas/device.py create mode 100644 wiregui/schemas/rule.py create mode 100644 wiregui/schemas/user.py create mode 100644 wiregui/services/__init__.py create mode 100644 wiregui/services/email.py create mode 100644 wiregui/services/events.py create mode 100644 wiregui/services/firewall.py create mode 100644 wiregui/services/notifications.py create mode 100644 wiregui/services/wireguard.py create mode 100644 wiregui/tasks/__init__.py create mode 100644 wiregui/tasks/connectivity.py create mode 100644 wiregui/tasks/oidc_refresh.py create mode 100644 wiregui/tasks/reconcile.py create mode 100644 wiregui/tasks/stats.py create mode 100644 wiregui/tasks/vpn_session.py create mode 100644 wiregui/utils/__init__.py create mode 100644 wiregui/utils/crypto.py create mode 100644 wiregui/utils/network.py create mode 100644 wiregui/utils/server_key.py create mode 100644 wiregui/utils/time.py create mode 100644 wiregui/utils/wg_conf.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..87b976d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.venv/ +__pycache__/ +*.pyc +.env +.nicegui/ +logs/ +.git/ +.idea/ +.pytest_cache/ +tests/ +.forgejo/ +*.md +compose*.yml diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..bf1f8c6 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,211 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: docker + container: + image: python:3.13-slim + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: wiregui + POSTGRES_PASSWORD: wiregui + POSTGRES_DB: wiregui + options: >- + --health-cmd "pg_isready -U wiregui" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + env: + WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + apt-get update && apt-get install -y --no-install-recommends \ + wireguard-tools pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl + + - name: Install uv + run: pip install uv + + - name: Install dependencies + run: uv sync + + - name: Run tests + run: uv run pytest -v --tb=short + + release: + needs: test + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: docker + outputs: + new_tag: ${{ steps.version.outputs.new_tag }} + new_version: ${{ steps.version.outputs.new_version }} + skip: ${{ steps.version.outputs.skip }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version bump + id: version + run: | + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "latest_tag=${LATEST_TAG}" >> "$GITHUB_OUTPUT" + + CURRENT="${LATEST_TAG#v}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + + COMMITS=$(git log "${LATEST_TAG}..HEAD" --pretty=format:"%s" 2>/dev/null || git log --pretty=format:"%s") + + BUMP="none" + while IFS= read -r msg; do + case "$msg" in + *"BREAKING CHANGE"*|*"!:"*) + BUMP="major" + break + ;; + feat:*|feat\(*) + [ "$BUMP" != "major" ] && BUMP="minor" + ;; + fix:*|fix\(*|perf:*|perf\(*|refactor:*|refactor\(*) + [ "$BUMP" = "none" ] && BUMP="patch" + ;; + esac + done <<< "$COMMITS" + + if [ "$BUMP" = "none" ]; then + echo "No version-relevant commits since ${LATEST_TAG}, skipping release" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + case "$BUMP" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + echo "new_version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" + echo "new_tag=v${NEW_VERSION}" >> "$GITHUB_OUTPUT" + echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "Version bump: ${BUMP} -> v${NEW_VERSION}" + + - name: Generate changelog + id: changelog + if: steps.version.outputs.skip != 'true' + run: | + LATEST_TAG="${{ steps.version.outputs.latest_tag }}" + NEW_TAG="${{ steps.version.outputs.new_tag }}" + + BODY="## ${NEW_TAG}"$'\n\n' + + for type_label in "feat:Features" "fix:Bug Fixes" "refactor:Refactoring" "perf:Performance" "docs:Documentation" "chore:Maintenance"; do + prefix="${type_label%%:*}" + label="${type_label#*:}" + MATCHES=$(git log "${LATEST_TAG}..HEAD" --pretty=format:"%s" 2>/dev/null | grep -E "^${prefix}[:(]" || true) + if [ -n "$MATCHES" ]; then + BODY="${BODY}### ${label}"$'\n\n' + while IFS= read -r line; do + CLEAN=$(echo "$line" | sed -E "s/^${prefix}(\([^)]*\))?:\s*//") + BODY="${BODY}- ${CLEAN}"$'\n' + done <<< "$MATCHES" + BODY="${BODY}"$'\n' + fi + done + + echo "${BODY}" > /tmp/changelog.md + echo "Generated changelog for ${NEW_TAG}" + + - name: Create tag and release + if: steps.version.outputs.skip != 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NEW_TAG="${{ steps.version.outputs.new_tag }}" + + git config user.name "Forgejo Actions" + git config user.email "noreply@forge.provvedo.com" + git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}" + git push origin "${NEW_TAG}" + + FORGEJO_URL="${GITHUB_SERVER_URL}" + REPO="${GITHUB_REPOSITORY}" + + python3 -c " + import json, urllib.request, os + body = open('/tmp/changelog.md').read() + tag = '${NEW_TAG}' + data = json.dumps({ + 'tag_name': tag, + 'name': tag, + 'body': body, + 'draft': False, + 'prerelease': False + }).encode() + req = urllib.request.Request( + '${FORGEJO_URL}/api/v1/repos/${REPO}/releases', + data=data, + headers={ + 'Authorization': 'token ' + os.environ['GITHUB_TOKEN'], + 'Content-Type': 'application/json' + }, + method='POST' + ) + resp = urllib.request.urlopen(req) + print(f'Created release {tag} (HTTP {resp.status})') + " + + docker: + needs: release + if: needs.release.outputs.skip != 'true' + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + options: --privileged + steps: + - uses: actions/checkout@v4 + + - name: Build and push image + shell: bash + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + VERSION="${{ needs.release.outputs.new_version }}" + TAG="${{ needs.release.outputs.new_tag }}" + REGISTRY=$(echo "${{ github.server_url }}" | sed 's|https://||; s|http://||') + IMAGE="${REGISTRY}/${{ github.repository_owner }}/wiregui" + + MAJOR=$(echo "$VERSION" | cut -d. -f1) + MINOR=$(echo "$VERSION" | cut -d. -f2) + + echo "Building ${IMAGE}:${TAG}" + + # Log in to Forgejo container registry + echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" \ + -u "${{ github.repository_owner }}" --password-stdin + + # Build the image + docker build --network host \ + --build-arg "VERSION=${VERSION}" \ + -t "${IMAGE}:${TAG}" \ + -t "${IMAGE}:${MAJOR}.${MINOR}" \ + -t "${IMAGE}:latest" \ + . + + # Push all tags + docker push "${IMAGE}:${TAG}" + docker push "${IMAGE}:${MAJOR}.${MINOR}" + docker push "${IMAGE}:latest" + + echo "Pushed ${IMAGE}:${TAG}, ${IMAGE}:${MAJOR}.${MINOR}, ${IMAGE}:latest" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c981dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.env +.nicegui/ +logs/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..931f036 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,124 @@ +# WireGUI + +## Project Overview +WireGUI is a Python rewrite of the Wirezone VPN management platform (Elixir/Phoenix). +Original source: `/home/stefanob/PycharmProjects/personal/wirezone` + +## Tech Stack +- **UI Framework**: NiceGUI (reactive server-side UI over WebSocket) +- **ORM/Models**: SQLModel (SQLAlchemy + Pydantic) +- **Database**: PostgreSQL (via asyncpg) +- **Cache/Sessions**: Valkey (Redis-compatible) +- **Migrations**: Alembic +- **REST API**: FastAPI (built into NiceGUI) +- **Auth**: authlib (OIDC), python-jose (JWT), pyotp (TOTP), webauthn, bcrypt +- **VPN**: subprocess calls to `wg` and `ip` commands +- **Firewall**: python-nftables or subprocess `nft` +- **Python**: 3.13 +- **Package Manager**: uv + +## Development Setup +```bash +uv sync # Install dependencies +docker compose up -d # Start PostgreSQL and Valkey +alembic upgrade head # Run migrations +uv run python -m wiregui.main # Start the application +``` + +## Project Structure +``` +wiregui/ +├── main.py # NiceGUI entrypoint, mounts FastAPI, starts background tasks +├── config.py # pydantic-settings: Settings class +├── db.py # async SQLAlchemy engine + sessionmaker +├── redis.py # Valkey connection pool +├── models/ # SQLModel table definitions +│ ├── user.py +│ ├── device.py +│ ├── rule.py +│ ├── mfa_method.py +│ ├── oidc_connection.py +│ ├── api_token.py +│ ├── connectivity_check.py +│ └── configuration.py +├── schemas/ # Pydantic request/response schemas (non-table) +├── auth/ # Authentication modules +│ ├── passwords.py # bcrypt hashing +│ ├── jwt.py # JWT create/verify +│ ├── session.py # NiceGUI session middleware +│ ├── oidc.py # authlib OIDC +│ ├── saml.py # python3-saml +│ ├── mfa.py # TOTP + WebAuthn +│ └── api_token.py # API token auth +├── api/v0/ # REST API routers +│ ├── users.py +│ ├── devices.py +│ ├── rules.py +│ └── configuration.py +├── pages/ # NiceGUI page definitions +│ ├── layout.py # shared sidebar/header +│ ├── login.py +│ ├── devices.py # user device CRUD +│ ├── account.py # user account/MFA +│ ├── mfa_challenge.py +│ └── admin/ # admin pages +│ ├── users.py +│ ├── devices.py +│ ├── rules.py +│ ├── settings.py +│ └── diagnostics.py +├── services/ # Core services +│ ├── wireguard.py # WG interface management +│ ├── firewall.py # nftables rule management +│ ├── events.py # DB → WG/firewall bridge +│ ├── notifications.py # in-memory notification queue +│ └── email.py # aiosmtplib +├── tasks/ # Background tasks +│ ├── vpn_session.py # expire VPN sessions +│ ├── stats.py # poll WG stats +│ ├── connectivity.py # WAN connectivity checks +│ └── oidc_refresh.py # refresh OIDC tokens +└── utils/ # Utilities + ├── crypto.py # keypair gen, Fernet encrypt/decrypt + ├── network.py # IP allocation, CIDR validation + └── validators.py # shared validators +alembic/ +├── env.py +├── script.py.mako +└── versions/ +``` + +## Commands +- `uv sync` — install/update dependencies +- `uv run python -m wiregui.main` — run the app +- `alembic revision --autogenerate -m "description"` — create migration +- `alembic upgrade head` — apply all migrations +- `alembic downgrade -1` — rollback last migration +- `docker compose up -d` — start local Postgres + Valkey +- `docker compose down` — stop local services +- `pytest` — run tests + +## Conventions +- Use SQLModel for all database models (combines SQLAlchemy table + Pydantic validation) +- Use async database sessions with asyncpg +- Place all NiceGUI pages in `wiregui/pages/` +- Place all SQLModel table models in `wiregui/models/` +- Place Pydantic request/response schemas in `wiregui/schemas/` +- Use Alembic autogenerate for migrations +- Background tasks use asyncio (create_task + while/sleep pattern) +- WireGuard/nftables managed via subprocess (asyncio.create_subprocess_exec) +- DB is source of truth; WG/firewall state is reconciled on startup + +## Logging — MANDATORY +- **Use loguru for ALL logging and messages. No `print()` statements allowed anywhere in this project.** +- Import: `from loguru import logger` +- Use `logger.info()`, `logger.warning()`, `logger.error()`, `logger.debug()`, etc. +- Loguru is configured in `wiregui/logging.py` via `setup_logging()` +- When `WG_LOG_TO_FILE=true` (default), timestamped log files are written to `logs/` in the project root +- The `logs/` directory is gitignored + +## Testing +- Tests live in `tests/` mirroring the `wiregui/` structure +- Run with `uv run pytest` +- Use `pytest-asyncio` for async tests +- Test database: uses same Postgres instance, separate `wiregui_test` database diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fea08f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +FROM python:3.13-slim AS builder + +WORKDIR /app + +# Install uv for fast dependency resolution +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Install system deps needed for building (wireguard-tools for wg CLI) +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc libpq-dev wireguard-tools nftables iproute2 \ + && rm -rf /var/lib/apt/lists/* + +# Copy dependency files first for layer caching +COPY pyproject.toml uv.lock* ./ + +# Install dependencies (production only, no dev group) +RUN uv sync --no-dev --frozen 2>/dev/null || uv sync --no-dev + +# Copy application code +COPY wiregui/ wiregui/ +COPY alembic/ alembic/ +COPY alembic.ini ./ + +FROM python:3.13-slim AS runner + +WORKDIR /app + +# Runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + wireguard-tools nftables iproute2 libpq5 \ + && rm -rf /var/lib/apt/lists/* + +# Copy uv and virtualenv from builder +COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app/wiregui /app/wiregui +COPY --from=builder /app/alembic /app/alembic +COPY --from=builder /app/alembic.ini /app/alembic.ini +COPY --from=builder /app/pyproject.toml /app/pyproject.toml + +# Ensure the venv is on PATH +ENV PATH="/app/.venv/bin:$PATH" +ENV PYTHONUNBUFFERED=1 + +# Create logs directory +RUN mkdir -p /app/logs + +ARG VERSION=0.0.0-dev +ENV WG_VERSION=$VERSION + +EXPOSE 13000 +EXPOSE 51820/udp + +# Run migrations then start the app +CMD ["sh", "-c", "alembic upgrade head && python -m wiregui.main"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..17e555b --- /dev/null +++ b/TODO.md @@ -0,0 +1,196 @@ +# WireGUI Implementation TODO + +Migration of Wirezone (Elixir/Phoenix) to Python/NiceGUI. +Source: `/home/stefanob/PycharmProjects/personal/wirezone` + +**Test count: 164 passing | Coverage: 35%** + +--- + +## Phase 1: Foundation — Models, DB, Config ✅ + +- [x] `pyproject.toml` with dependencies, `uv sync` +- [x] Package directory structure +- [x] `wiregui/config.py` — pydantic-settings (DB, Redis, WG, auth, SMTP, logging) +- [x] `wiregui/db.py` — async engine, sessionmaker, `init_db()` +- [x] `wiregui/redis.py` — Valkey connection pool +- [x] All 8 SQLModel models (User, Device, Rule, MFAMethod, OIDCConnection, ApiToken, ConnectivityCheck, Configuration) +- [x] Alembic init + initial migration + `alembic upgrade head` +- [x] `wiregui/main.py` — app entrypoint +- [x] `compose.yml` — PostgreSQL 17 + Valkey 8 +- [x] `wiregui/utils/time.py` — `utcnow()` helper for naive UTC timestamps + +--- + +## Phase 2: Auth System — Login + Sessions ✅ + +- [x] `wiregui/auth/passwords.py` — bcrypt hash/verify (direct bcrypt, not passlib) +- [x] `wiregui/auth/jwt.py` — create/decode JWT via python-jose +- [x] `wiregui/auth/session.py` — `authenticate_user()` email/password verification +- [x] `wiregui/auth/middleware.py` — HTTP-level auth middleware (available for REST API) +- [x] `wiregui/auth/seed.py` — auto-create admin on first startup +- [x] `wiregui/pages/login.py` — login page with email/password form +- [x] `wiregui/pages/home.py` — authenticated home (redirects to /devices) +- [x] Auth guards via `app.storage.user` on each page +- [x] Logout clears storage and redirects + +--- + +## Phase 3: Device UI — User-Facing CRUD ✅ + +- [x] `wiregui/pages/layout.py` — shared sidebar + header +- [x] `wiregui/utils/network.py` — IPv4/IPv6 allocation (random offset + scan) +- [x] `wiregui/utils/crypto.py` — WG keypair + PSK generation via `wg` CLI +- [x] `wiregui/utils/wg_conf.py` — WG client `.conf` builder +- [x] `wiregui/pages/devices.py` — `/devices` list + create dialog + delete +- [x] `/devices/{device_id}` — detail page with stats and config flags +- [x] QR code generation + `.conf` download +- [x] Full device create/edit form with all wirezone options (description, per-device config overrides, use_default_* toggles with bound inputs, better layout) + +--- + +## Phase 4: WireGuard Integration ✅ + +- [x] `wiregui/services/wireguard.py` — async subprocess: ensure_interface, add/remove_peer, get_peers, set_private_key, set_listen_port +- [x] `wiregui/services/events.py` — event bridge: device CRUD → WG + firewall +- [x] Device create/delete in UI fires WG events +- [x] `wiregui/tasks/__init__.py` — background task registry + cancel_all +- [x] `wiregui/tasks/stats.py` — poll WG stats every 60s, update DB +- [x] `wiregui/tasks/reconcile.py` — startup reconciliation (diff DB vs WG, add/remove) +- [x] `config.py` — `wg_enabled` flag (default False for dev) +- [x] Startup: ensure_interface → reconcile → stats_loop (when wg_enabled) + +--- + +## Phase 5: Firewall (nftables) ✅ + +- [x] `wiregui/services/firewall.py` — nft CLI: setup_base_tables, masquerade, per-user chains, jump rules, apply_rule, rebuild_all_rules +- [x] IPv4/IPv6 aware, TCP/UDP port range support +- [x] `wiregui/pages/admin/rules.py` — `/admin/rules` CRUD (action, CIDR, protocol, port, user) +- [x] Events: on_rule_created/deleted, on_device_created adds jump rules +- [x] Startup: setup_base_tables + setup_masquerade (when wg_enabled) +- [x] Edit rule — edit dialog in admin rules page with all fields +- [x] Full user chain rebuild on rule update/delete via `_rebuild_user_chain()` in events.py + +--- + +## Phase 6: REST API (v0) ✅ + +- [x] `wiregui/auth/api_token.py` — token generation (random → sha256), Bearer resolution with expiry + disabled user checks +- [x] `wiregui/api/deps.py` — get_db, get_current_api_user, require_admin +- [x] `wiregui/schemas/` — Pydantic schemas: UserRead/Create/Update, DeviceRead/Create/Update, RuleRead/Create/Update, ConfigurationRead/Update +- [x] `wiregui/api/v0/users.py` — full CRUD (admin only) +- [x] `wiregui/api/v0/devices.py` — full CRUD (owner or admin, triggers WG/firewall events) +- [x] `wiregui/api/v0/rules.py` — full CRUD (admin only, triggers firewall events) +- [x] `wiregui/api/v0/configuration.py` — GET/PUT (admin only, auto-creates singleton) +- [x] Mounted on NiceGUI app at `/api/v0` + +--- + +## Phase 7: Admin UI ✅ + +- [x] `/admin/users` — table (email, role, devices, status, last sign-in, method, created), create (email/password/role), edit (email/role/password/disabled), delete with cascading cleanup (devices → WG events, rules) +- [x] `/admin/devices` — all devices with user filter, full create form (owner, name, description, all use_default_* toggles with bound override inputs), full edit form, delete with WG events, config + QR on creation +- [x] `/admin/settings` — 3 tabs: + - Client Defaults (endpoint, DNS, allowed IPs, MTU, keepalive) + - Security (VPN session duration, local auth, unpriv device mgmt/config, OIDC auto-disable) + - Authentication (OIDC provider CRUD with table + dialog; SAML placeholder for Phase 8) +- [x] `/admin/diagnostics` — WG interface status, active peers, connectivity checks, system notifications with clear/clear-all +- [x] `wiregui/services/notifications.py` — in-memory deque (capped at 100), add/clear/count/current +- [x] Header notification bell badge (admin only, links to diagnostics) +- [ ] **TODO:** SAML provider management in Authentication tab + +--- + +## Phase 8: Advanced Auth (MFA, OIDC, Magic Links, SAML) ✅ + +- [x] TOTP MFA (`wiregui/auth/mfa.py`) — secret generation, URI/QR, verification with clock drift tolerance +- [x] MFA challenge page (`/mfa`) — 6-digit code entry, multi-method support, last-used tracking +- [x] Login page updated: checks for MFA methods after password auth, redirects to `/mfa` if present +- [x] OIDC (`wiregui/auth/oidc.py`) — provider registry from Configuration, authlib Starlette integration +- [x] OIDC routes (`/auth/oidc/{provider}` + `/auth/oidc/{provider}/callback`) — auth code flow, user lookup/auto-create, refresh token storage in OIDCConnection +- [x] Login page shows OIDC provider buttons dynamically from config +- [x] OIDC refresh task (`wiregui/tasks/oidc_refresh.py`) — every 10min, refreshes all stored tokens, creates notifications on failure, respects `disable_vpn_on_oidc_error` +- [x] Magic links (`/auth/magic-link` + `/auth/magic/{user_id}/{token}`) — request page, signed JWT with 15min expiry, email via aiosmtplib +- [x] Email service (`wiregui/services/email.py`) — aiosmtplib send, magic link template +- [x] `/account` page — 3 tabs: Profile (details + password change), Two-Factor Auth (TOTP registration with QR + verification, list/delete methods), API Tokens (create with configurable expiry, list, delete) +- [x] OIDC providers registered on startup from Configuration +- [x] WebAuthn MFA (`wiregui/auth/webauthn.py`) — registration/authentication options generation, response verification, credential storage +- [x] SAML (`wiregui/auth/saml.py` + `wiregui/pages/auth_saml.py`) — SP-initiated SSO, metadata endpoint, ACS callback, IdP metadata parsing, attribute mapping +- [x] WebAuthn browser-side JS integration in account page — `ui.run_javascript()` calls `navigator.credentials.create()`, serializes response, server verifies and stores credential +- [x] SAML provider management UI in admin settings Authentication tab — table + add/delete dialog (config ID, label, XML metadata, sign requests/metadata/assertions/envelopes toggles, auto-create users) + +--- + +## Phase 9: Background Tasks & VPN Session Management + +- [x] Task scheduler (`wiregui/tasks/__init__.py`) — register/cancel +- [x] Stats polling task (Phase 4) +- [x] OIDC refresh task (Phase 8) +- [x] VPN session expiry task (`wiregui/tasks/vpn_session.py`) — every 60s, finds expired sessions based on `vpn_session_duration` + `last_signed_in_at`, removes WG peers, creates notifications +- [x] Connectivity check poller (`wiregui/tasks/connectivity.py`) — fetches URL, stores result in DB, notification on failure +- [x] Live stats push — `ui.timer(30, ...)` on `/devices` (table refresh), `/devices/{id}` (RX/TX/handshake/remote IP labels), `/admin/devices` (table refresh) + +--- + +## Phase 10: Polish, Testing & Deployment + +### Testing (partially done) +- [x] pytest + pytest-asyncio setup, conftest with test DB +- [x] test_models.py (10 tests), test_auth.py (8 tests), test_utils.py (6 tests), test_services.py (6 tests), test_firewall.py (7 tests) +- [x] test_api.py (6 tests) — token generation, resolution, expiry, disabled user +- [x] test_notifications.py (9 tests) — add, ordering, count, clear, max cap, to_dict +- [x] test_admin.py (13 tests) — user CRUD, cascading deletes, config CRUD, OIDC providers, device overrides +- [x] test_mfa.py (11 tests) — TOTP secret gen, URI, code verification (valid/invalid/wrong secret/empty), QR SVG, DB integration, multi-method +- [x] test_magic_link.py (4 tests) — token creation/expiry/user mismatch, disabled user rejection +- [x] test_account.py (8 tests) — password change flow, API token CRUD, OIDC connection CRUD, refresh token update +- [x] test_integration_mfa.py (7 tests) — full TOTP registration flow, MFA blocks login, wrong code, multi-method, last-used tracking, delete allows bypass, disabled user +- [x] test_integration_oidc.py (10 tests) — provider config loading, connection create/update, auto-create user, disabled user, refresh token, multi-provider +- [x] test_tasks.py (6 tests) — VPN session expiry (expired/unlimited/no-config/disabled user), connectivity check (success/failure with notification) +- [ ] HTTP-level integration tests (OIDC redirect/callback flow with respx mocking) + +### Coverage gaps (35% overall — run `uv run pytest --cov=wiregui --cov-report=term-missing --cov-branch`) + +**100% covered:** models, schemas, config, auth/passwords, auth/jwt, auth/mfa, auth/api_token, utils/crypto, utils/time, services/notifications + +**API routes (32-84% — partially covered via httpx TestClient):** +- [x] `wiregui/api/v0/users.py` (84%) — list/get/create/update/delete +- [x] `wiregui/api/v0/rules.py` (71%) — CRUD +- [x] `wiregui/api/v0/devices.py` (67%) — CRUD, permissions +- [x] `wiregui/api/v0/configuration.py` (61%) — get/update, auto-create +- [ ] `wiregui/api/deps.py` (32%) — test get_current_api_user with real Bearer header parsing, require_admin rejection + +**Services (62-89% covered):** +- [x] `wiregui/services/wireguard.py` (62%) — add/remove/get peers mocked +- [x] `wiregui/services/firewall.py` (73%) — base tables, chains, rules, rebuild mocked +- [x] `wiregui/services/events.py` (80%) — device + rule events, rebuild chain +- [x] `wiregui/services/email.py` (89%) — send_email, magic link, no-smtp fallback +- [ ] `wiregui/services/wireguard.py` — test ensure_interface, set_private_key, set_listen_port +- [ ] `wiregui/services/firewall.py` — test _nft/_nft_batch error handling, add_device_jump_rule with only ipv4/ipv6 + +**Tasks (40-84% covered):** +- [x] `wiregui/tasks/stats.py` (77%) — update from peers, no-op, unmatched peer +- [x] `wiregui/tasks/reconcile.py` (84%) — add missing, remove orphaned, in-sync +- [x] `wiregui/tasks/oidc_refresh.py` (40%) — no connections, skip unknown provider +- [ ] `wiregui/tasks/oidc_refresh.py` — test successful refresh, failure with notification, disable_vpn_on_oidc_error + +**Auth modules (85-92% covered):** +- [x] `wiregui/auth/oidc.py` (87%) — register providers, get_client, load from config +- [x] `wiregui/auth/webauthn.py` (85%) — registration/authentication options +- [x] `wiregui/auth/session.py` (90%) — no-password, disabled, nonexistent user +- [ ] `wiregui/auth/saml.py` (0%) — needs mock SAML IdP metadata + response parsing +- [ ] `wiregui/auth/webauthn.py` — test verify_registration, verify_authentication with mock credential data + +**Pages (0% — requires E2E testing):** +- [ ] Consider Playwright or NiceGUI's testing utilities for E2E page tests + +### Logging (done) +- [x] Loguru configured (wiregui/logging.py), no print statements +- [x] File logging to `logs/` when `WG_LOG_TO_FILE=true` + +### Deployment +- [ ] Dockerfile (multi-stage) +- [ ] compose.prod.yml (app + postgres + valkey + caddy) +- [ ] Health endpoint `GET /api/health` +- [ ] First-run CLI setup command +- [ ] README.md diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..1060730 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +prepend_sys_path = . + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..0381820 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,47 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine +from sqlmodel import SQLModel + +from wiregui.config import get_settings +from wiregui.models import * # noqa: F401, F403 — ensure all models are registered + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = SQLModel.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode — emit SQL to script output.""" + context.configure( + url=get_settings().database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """Run migrations in 'online' mode — connect to the database.""" + engine = create_async_engine(get_settings().database_url) + async with engine.connect() as connection: + await connection.run_sync(do_run_migrations) + await engine.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..6ce3351 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0741bc76e748_add_server_keypair_to_configuration.py b/alembic/versions/0741bc76e748_add_server_keypair_to_configuration.py new file mode 100644 index 0000000..e7fb79d --- /dev/null +++ b/alembic/versions/0741bc76e748_add_server_keypair_to_configuration.py @@ -0,0 +1,33 @@ +"""add server keypair to configuration + +Revision ID: 0741bc76e748 +Revises: 647a4418cc8c +Create Date: 2026-03-30 15:37:19.276524 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '0741bc76e748' +down_revision: Union[str, None] = '647a4418cc8c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('configurations', sa.Column('server_private_key', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.add_column('configurations', sa.Column('server_public_key', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('configurations', 'server_public_key') + op.drop_column('configurations', 'server_private_key') + # ### end Alembic commands ### diff --git a/alembic/versions/647a4418cc8c_initial_schema.py b/alembic/versions/647a4418cc8c_initial_schema.py new file mode 100644 index 0000000..19ba096 --- /dev/null +++ b/alembic/versions/647a4418cc8c_initial_schema.py @@ -0,0 +1,171 @@ +"""initial schema + +Revision ID: 647a4418cc8c +Revises: +Create Date: 2026-03-30 13:18:58.766259 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '647a4418cc8c' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('configurations', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('allow_unprivileged_device_management', sa.Boolean(), nullable=False), + sa.Column('allow_unprivileged_device_configuration', sa.Boolean(), nullable=False), + sa.Column('local_auth_enabled', sa.Boolean(), nullable=False), + sa.Column('disable_vpn_on_oidc_error', sa.Boolean(), nullable=False), + sa.Column('default_client_persistent_keepalive', sa.Integer(), nullable=False), + sa.Column('default_client_mtu', sa.Integer(), nullable=False), + sa.Column('default_client_endpoint', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('default_client_dns', sa.JSON(), nullable=True), + sa.Column('default_client_allowed_ips', sa.JSON(), nullable=True), + sa.Column('vpn_session_duration', sa.Integer(), nullable=False), + sa.Column('logo_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('logo_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('openid_connect_providers', sa.JSON(), nullable=True), + sa.Column('saml_identity_providers', sa.JSON(), nullable=True), + sa.Column('inserted_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('connectivity_checks', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('response_code', sa.Integer(), nullable=True), + sa.Column('response_headers', sa.JSON(), nullable=True), + sa.Column('response_body', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('inserted_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('last_signed_in_at', sa.DateTime(), nullable=True), + sa.Column('last_signed_in_method', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('sign_in_token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('sign_in_token_created_at', sa.DateTime(), nullable=True), + sa.Column('disabled_at', sa.DateTime(), nullable=True), + sa.Column('inserted_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('api_tokens', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('inserted_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_api_tokens_token_hash'), 'api_tokens', ['token_hash'], unique=True) + op.create_index(op.f('ix_api_tokens_user_id'), 'api_tokens', ['user_id'], unique=False) + op.create_table('devices', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('public_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('preshared_key', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('use_default_allowed_ips', sa.Boolean(), nullable=False), + sa.Column('use_default_dns', sa.Boolean(), nullable=False), + sa.Column('use_default_endpoint', sa.Boolean(), nullable=False), + sa.Column('use_default_mtu', sa.Boolean(), nullable=False), + sa.Column('use_default_persistent_keepalive', sa.Boolean(), nullable=False), + sa.Column('endpoint', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('mtu', sa.Integer(), nullable=True), + sa.Column('persistent_keepalive', sa.Integer(), nullable=True), + sa.Column('allowed_ips', sa.JSON(), nullable=True), + sa.Column('dns', sa.JSON(), nullable=True), + sa.Column('ipv4', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('ipv6', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('remote_ip', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('rx_bytes', sa.Integer(), nullable=True), + sa.Column('tx_bytes', sa.Integer(), nullable=True), + sa.Column('latest_handshake', sa.DateTime(), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('inserted_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('ipv4'), + sa.UniqueConstraint('ipv6') + ) + op.create_index(op.f('ix_devices_public_key'), 'devices', ['public_key'], unique=True) + op.create_index(op.f('ix_devices_user_id'), 'devices', ['user_id'], unique=False) + op.create_table('mfa_methods', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('payload', sa.JSON(), nullable=True), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('inserted_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_mfa_methods_user_id'), 'mfa_methods', ['user_id'], unique=False) + op.create_table('oidc_connections', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('refresh_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('refresh_response', sa.JSON(), nullable=True), + sa.Column('refreshed_at', sa.DateTime(), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('inserted_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_oidc_connections_user_id'), 'oidc_connections', ['user_id'], unique=False) + op.create_table('rules', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('action', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('destination', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('port_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('port_range', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=True), + sa.Column('inserted_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_rules_user_id'), 'rules', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_rules_user_id'), table_name='rules') + op.drop_table('rules') + op.drop_index(op.f('ix_oidc_connections_user_id'), table_name='oidc_connections') + op.drop_table('oidc_connections') + op.drop_index(op.f('ix_mfa_methods_user_id'), table_name='mfa_methods') + op.drop_table('mfa_methods') + op.drop_index(op.f('ix_devices_user_id'), table_name='devices') + op.drop_index(op.f('ix_devices_public_key'), table_name='devices') + op.drop_table('devices') + op.drop_index(op.f('ix_api_tokens_user_id'), table_name='api_tokens') + op.drop_index(op.f('ix_api_tokens_token_hash'), table_name='api_tokens') + op.drop_table('api_tokens') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_table('connectivity_checks') + op.drop_table('configurations') + # ### end Alembic commands ### diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 0000000..f5394d3 --- /dev/null +++ b/compose.prod.yml @@ -0,0 +1,63 @@ +services: + wiregui: + build: + context: . + dockerfile: Dockerfile + image: wiregui:latest + restart: unless-stopped + ports: + - "13000:13000" + - "51821:51821/udp" + cap_add: + - NET_ADMIN + - SYS_MODULE + sysctls: + - net.ipv4.ip_forward=1 + - net.ipv6.conf.all.forwarding=1 + - net.ipv6.conf.all.disable_ipv6=0 + environment: + WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui + WG_REDIS_URL: redis://valkey:6379/0 + WG_SECRET_KEY: ${WG_SECRET_KEY:-change-me-in-production} + WG_WG_ENABLED: "true" + WG_WG_ENDPOINT_HOST: ${WG_ENDPOINT_HOST:-vpn.example.com} + WG_WG_ENDPOINT_PORT: "51821" + WG_HOST: "0.0.0.0" + WG_PORT: "13000" + WG_EXTERNAL_URL: ${WG_EXTERNAL_URL:-http://localhost:13000} + WG_ADMIN_EMAIL: ${WG_ADMIN_EMAIL:-admin@localhost} + WG_ADMIN_PASSWORD: ${WG_ADMIN_PASSWORD:-} + WG_LOG_TO_FILE: "true" + volumes: + - wiregui_logs:/app/logs + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_started + + postgres: + image: postgres:17 + restart: unless-stopped + environment: + POSTGRES_USER: wiregui + POSTGRES_PASSWORD: wiregui + POSTGRES_DB: wiregui + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wiregui"] + interval: 5s + timeout: 5s + retries: 5 + + valkey: + image: valkey/valkey:8 + restart: unless-stopped + volumes: + - valkey_data:/data + +volumes: + postgres_data: + valkey_data: + wiregui_logs: diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..c0d4478 --- /dev/null +++ b/compose.yml @@ -0,0 +1,22 @@ +services: + postgres: + image: postgres:17 + environment: + POSTGRES_USER: wiregui + POSTGRES_PASSWORD: wiregui + POSTGRES_DB: wiregui + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + valkey: + image: valkey/valkey:8 + ports: + - "6379:6379" + volumes: + - valkey_data:/data + +volumes: + postgres_data: + valkey_data: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0094cfd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "wiregui" +version = "0.1.0" +description = "WireGuard VPN management platform — Python/NiceGUI rewrite of Wirezone" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + # UI + "nicegui>=2.12", + # ORM & Database + "sqlmodel>=0.0.22", + "asyncpg>=0.30", + "alembic>=1.14", + # Configuration + "pydantic-settings>=2.7", + # Cache + "redis>=5.2", + # Encryption + "cryptography>=44", + # Auth + "bcrypt>=4.0", + "python-jose[cryptography]>=3.3", + "authlib>=1.4", + "pyotp>=2.9", + "webauthn>=2.2", + "python3-saml>=1.16", + # HTTP client + "httpx>=0.28", + # Email + "aiosmtplib>=3.0", + # QR codes + "qrcode[pil]>=8.0", + # Logging + "loguru>=0.7.3", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "pytest-cov>=7.1.0", + "respx>=0.22.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3014bc5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +"""Shared test fixtures — async DB session using a test database.""" + +from collections.abc import AsyncGenerator + +import pytest +import pytest_asyncio +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlmodel import SQLModel + +from wiregui.config import get_settings + +# All models must be imported so SQLModel.metadata knows about them +from wiregui.models import * # noqa: F401, F403 + + +def _test_database_url() -> str: + url = get_settings().database_url + base, _dbname = url.rsplit("/", 1) + return f"{base}/wiregui_test" + + +TEST_DATABASE_URL = _test_database_url() + +# Module-level engine creation (runs once via autouse session fixture) +_engine = None + + +def _ensure_test_db_sync(): + """Ensure wiregui_test database exists (called once).""" + import asyncio + + async def _create(): + base_url = get_settings().database_url.rsplit("/", 1)[0] + "/postgres" + admin_engine = create_async_engine(base_url, isolation_level="AUTOCOMMIT") + async with admin_engine.connect() as conn: + result = await conn.execute( + text("SELECT 1 FROM pg_database WHERE datname = 'wiregui_test'") + ) + if result.scalar() is None: + await conn.execute(text("CREATE DATABASE wiregui_test")) + await admin_engine.dispose() + + asyncio.run(_create()) + + +# Create test DB once at import time +_ensure_test_db_sync() + + +@pytest_asyncio.fixture +async def session() -> AsyncGenerator[AsyncSession]: + """Fresh engine + session per test, with table setup/teardown.""" + engine = create_async_engine(TEST_DATABASE_URL) + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as sess: + yield sess + await sess.rollback() + + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + await engine.dispose() diff --git a/tests/test_account.py b/tests/test_account.py new file mode 100644 index 0000000..067c088 --- /dev/null +++ b/tests/test_account.py @@ -0,0 +1,161 @@ +"""Tests for account functionality — password changes, API tokens, OIDC connections.""" + +import hashlib +from datetime import timedelta + +from sqlmodel import func, select + +from wiregui.auth.api_token import generate_api_token +from wiregui.auth.passwords import hash_password, verify_password +from wiregui.models.api_token import ApiToken +from wiregui.models.oidc_connection import OIDCConnection +from wiregui.models.user import User +from wiregui.utils.time import utcnow + + +# --- Password change --- + + +async def test_password_change_flow(session): + """Simulate the password change flow: verify old, set new.""" + user = User(email="pw-change@example.com", password_hash=hash_password("old-password")) + session.add(user) + await session.flush() + + # Verify old password + assert verify_password("old-password", user.password_hash) is True + + # Change password + user.password_hash = hash_password("new-password") + session.add(user) + await session.flush() + + fetched = await session.get(User, user.id) + assert verify_password("new-password", fetched.password_hash) is True + assert verify_password("old-password", fetched.password_hash) is False + + +async def test_password_change_wrong_current(session): + """Wrong current password should not allow change.""" + user = User(email="pw-wrong@example.com", password_hash=hash_password("correct")) + session.add(user) + await session.flush() + + # Simulate check + assert verify_password("wrong", user.password_hash) is False + + +# --- API token management --- + + +async def test_create_multiple_tokens(session): + user = User(email="multi-token@example.com") + session.add(user) + await session.flush() + + for _ in range(3): + _, token_hash = generate_api_token() + session.add(ApiToken(token_hash=token_hash, user_id=user.id)) + await session.flush() + + count = (await session.execute( + select(func.count()).select_from(ApiToken).where(ApiToken.user_id == user.id) + )).scalar() + assert count == 3 + + +async def test_token_with_expiry(session): + user = User(email="expiry-token@example.com") + session.add(user) + await session.flush() + + _, token_hash = generate_api_token() + expires = utcnow() + timedelta(days=30) + token = ApiToken(token_hash=token_hash, expires_at=expires, user_id=user.id) + session.add(token) + await session.flush() + + fetched = await session.get(ApiToken, token.id) + assert fetched.expires_at is not None + assert fetched.expires_at > utcnow() + + +async def test_delete_token(session): + user = User(email="del-token@example.com") + session.add(user) + await session.flush() + + _, token_hash = generate_api_token() + token = ApiToken(token_hash=token_hash, user_id=user.id) + session.add(token) + await session.flush() + + await session.delete(token) + await session.flush() + + assert await session.get(ApiToken, token.id) is None + + +# --- OIDC connections --- + + +async def test_oidc_connection_create(session): + user = User(email="oidc-conn@example.com") + session.add(user) + await session.flush() + + conn = OIDCConnection( + provider="google", + refresh_token="refresh-tok-123", + refresh_response={"access_token": "at", "token_type": "Bearer"}, + refreshed_at=utcnow(), + user_id=user.id, + ) + session.add(conn) + await session.flush() + + fetched = (await session.execute( + select(OIDCConnection).where(OIDCConnection.user_id == user.id) + )).scalar_one() + assert fetched.provider == "google" + assert fetched.refresh_token == "refresh-tok-123" + assert fetched.refresh_response["access_token"] == "at" + + +async def test_multiple_oidc_providers(session): + user = User(email="multi-oidc@example.com") + session.add(user) + await session.flush() + + for provider in ["google", "okta", "azure"]: + conn = OIDCConnection(provider=provider, user_id=user.id) + session.add(conn) + await session.flush() + + count = (await session.execute( + select(func.count()).select_from(OIDCConnection).where(OIDCConnection.user_id == user.id) + )).scalar() + assert count == 3 + + +async def test_oidc_connection_update_refresh_token(session): + user = User(email="oidc-refresh@example.com") + session.add(user) + await session.flush() + + conn = OIDCConnection( + provider="google", + refresh_token="old-token", + user_id=user.id, + ) + session.add(conn) + await session.flush() + + conn.refresh_token = "new-token" + conn.refreshed_at = utcnow() + session.add(conn) + await session.flush() + + fetched = await session.get(OIDCConnection, conn.id) + assert fetched.refresh_token == "new-token" + assert fetched.refreshed_at is not None diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..714b814 --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,283 @@ +"""Tests for admin functionality — user management, configuration, cascading deletes.""" + +import pytest +from sqlmodel import func, select + +from wiregui.auth.passwords import hash_password, verify_password +from wiregui.models.api_token import ApiToken +from wiregui.models.configuration import Configuration +from wiregui.models.device import Device +from wiregui.models.mfa_method import MFAMethod +from wiregui.models.rule import Rule +from wiregui.models.user import User +from wiregui.utils.time import utcnow + + +# --- User CRUD --- + + +async def test_create_user_with_role(session): + user = User(email="new-admin@test.com", password_hash=hash_password("secret"), role="admin") + session.add(user) + await session.flush() + + fetched = await session.get(User, user.id) + assert fetched.role == "admin" + assert verify_password("secret", fetched.password_hash) + + +async def test_update_user_email(session): + user = User(email="old@test.com", password_hash=hash_password("pw")) + session.add(user) + await session.flush() + + user.email = "new@test.com" + session.add(user) + await session.flush() + + fetched = await session.get(User, user.id) + assert fetched.email == "new@test.com" + + +async def test_disable_user(session): + user = User(email="active@test.com", password_hash=hash_password("pw")) + session.add(user) + await session.flush() + assert user.disabled_at is None + + user.disabled_at = utcnow() + session.add(user) + await session.flush() + + fetched = await session.get(User, user.id) + assert fetched.disabled_at is not None + + +async def test_promote_demote_user(session): + user = User(email="user@test.com", role="unprivileged") + session.add(user) + await session.flush() + assert user.role == "unprivileged" + + user.role = "admin" + session.add(user) + await session.flush() + + fetched = await session.get(User, user.id) + assert fetched.role == "admin" + + user.role = "unprivileged" + session.add(user) + await session.flush() + assert (await session.get(User, user.id)).role == "unprivileged" + + +# --- Cascading delete (manual, as we do it in the admin page) --- + + +async def test_delete_user_cascades_devices(session): + user = User(email="cascade@test.com") + session.add(user) + await session.flush() + + d1 = Device(name="d1", public_key="pk-cascade-1", ipv4="10.0.0.1", user_id=user.id) + d2 = Device(name="d2", public_key="pk-cascade-2", ipv4="10.0.0.2", user_id=user.id) + session.add_all([d1, d2]) + await session.flush() + + # Manually delete devices then user (matching admin page behavior) + devices = (await session.execute(select(Device).where(Device.user_id == user.id))).scalars().all() + for d in devices: + await session.delete(d) + await session.delete(user) + await session.flush() + + assert (await session.execute(select(func.count()).select_from(Device).where(Device.user_id == user.id))).scalar() == 0 + assert await session.get(User, user.id) is None + + +async def test_delete_user_cascades_rules(session): + user = User(email="rule-cascade@test.com") + session.add(user) + await session.flush() + + rule = Rule(action="accept", destination="10.0.0.0/8", user_id=user.id) + session.add(rule) + await session.flush() + + # Delete rules then user + rules = (await session.execute(select(Rule).where(Rule.user_id == user.id))).scalars().all() + for r in rules: + await session.delete(r) + await session.delete(user) + await session.flush() + + assert (await session.execute(select(func.count()).select_from(Rule).where(Rule.user_id == user.id))).scalar() == 0 + + +# --- Configuration singleton --- + + +async def test_configuration_create_and_update(session): + config = Configuration() + session.add(config) + await session.flush() + + assert config.default_client_mtu == 1280 + assert config.local_auth_enabled is True + + config.default_client_mtu = 1400 + config.local_auth_enabled = False + config.vpn_session_duration = 3600 + session.add(config) + await session.flush() + + fetched = await session.get(Configuration, config.id) + assert fetched.default_client_mtu == 1400 + assert fetched.local_auth_enabled is False + assert fetched.vpn_session_duration == 3600 + + +async def test_configuration_oidc_providers(session): + config = Configuration() + session.add(config) + await session.flush() + + assert config.openid_connect_providers == [] + + providers = [ + { + "id": "google", + "label": "Sign in with Google", + "scope": "openid email profile", + "response_type": "code", + "client_id": "google-client-id", + "client_secret": "google-secret", + "discovery_document_uri": "https://accounts.google.com/.well-known/openid-configuration", + "auto_create_users": True, + }, + { + "id": "okta", + "label": "Okta SSO", + "scope": "openid email profile", + "response_type": "code", + "client_id": "okta-client-id", + "client_secret": "okta-secret", + "discovery_document_uri": "https://dev-123.okta.com/.well-known/openid-configuration", + "auto_create_users": False, + }, + ] + config.openid_connect_providers = providers + session.add(config) + await session.flush() + + fetched = await session.get(Configuration, config.id) + assert len(fetched.openid_connect_providers) == 2 + assert fetched.openid_connect_providers[0]["id"] == "google" + assert fetched.openid_connect_providers[1]["auto_create_users"] is False + + +async def test_configuration_update_client_defaults(session): + config = Configuration() + session.add(config) + await session.flush() + + config.default_client_endpoint = "vpn.example.com" + config.default_client_dns = ["8.8.8.8", "8.8.4.4"] + config.default_client_allowed_ips = ["10.0.0.0/8"] + config.default_client_persistent_keepalive = 30 + session.add(config) + await session.flush() + + fetched = await session.get(Configuration, config.id) + assert fetched.default_client_endpoint == "vpn.example.com" + assert fetched.default_client_dns == ["8.8.8.8", "8.8.4.4"] + assert fetched.default_client_allowed_ips == ["10.0.0.0/8"] + assert fetched.default_client_persistent_keepalive == 30 + + +async def test_configuration_security_toggles(session): + config = Configuration() + session.add(config) + await session.flush() + + config.allow_unprivileged_device_management = False + config.allow_unprivileged_device_configuration = False + config.disable_vpn_on_oidc_error = True + session.add(config) + await session.flush() + + fetched = await session.get(Configuration, config.id) + assert fetched.allow_unprivileged_device_management is False + assert fetched.allow_unprivileged_device_configuration is False + assert fetched.disable_vpn_on_oidc_error is True + + +# --- Device config overrides --- + + +async def test_device_with_custom_config(session): + user = User(email="config-user@test.com") + session.add(user) + await session.flush() + + device = Device( + name="custom-config", + public_key="pk-custom-config", + user_id=user.id, + use_default_dns=False, + use_default_endpoint=False, + use_default_mtu=False, + use_default_persistent_keepalive=False, + use_default_allowed_ips=False, + dns=["8.8.8.8"], + endpoint="custom-vpn.example.com", + mtu=1400, + persistent_keepalive=15, + allowed_ips=["10.0.0.0/8", "172.16.0.0/12"], + ) + session.add(device) + await session.flush() + + fetched = await session.get(Device, device.id) + assert fetched.use_default_dns is False + assert fetched.dns == ["8.8.8.8"] + assert fetched.endpoint == "custom-vpn.example.com" + assert fetched.mtu == 1400 + assert fetched.persistent_keepalive == 15 + assert fetched.allowed_ips == ["10.0.0.0/8", "172.16.0.0/12"] + + +async def test_device_default_flags_are_true(session): + user = User(email="defaults@test.com") + session.add(user) + await session.flush() + + device = Device(name="defaults", public_key="pk-defaults", user_id=user.id) + session.add(device) + await session.flush() + + fetched = await session.get(Device, device.id) + assert fetched.use_default_allowed_ips is True + assert fetched.use_default_dns is True + assert fetched.use_default_endpoint is True + assert fetched.use_default_mtu is True + assert fetched.use_default_persistent_keepalive is True + + +# --- User device count --- + + +async def test_user_device_count_query(session): + user = User(email="count-user@test.com") + session.add(user) + await session.flush() + + for i in range(3): + session.add(Device(name=f"d{i}", public_key=f"pk-count-{i}", user_id=user.id)) + await session.flush() + + count = (await session.execute( + select(func.count()).select_from(Device).where(Device.user_id == user.id) + )).scalar() + assert count == 3 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..a793e34 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,86 @@ +"""Tests for REST API endpoints and token auth.""" + +import hashlib + +from wiregui.auth.api_token import generate_api_token, resolve_bearer_token +from wiregui.auth.passwords import hash_password +from wiregui.models.api_token import ApiToken +from wiregui.models.user import User +from wiregui.utils.time import utcnow + + +# --- Token generation --- + + +def test_generate_api_token(): + plaintext, token_hash = generate_api_token() + assert len(plaintext) > 20 + assert token_hash == hashlib.sha256(plaintext.encode()).hexdigest() + + +def test_generate_api_token_unique(): + t1, h1 = generate_api_token() + t2, h2 = generate_api_token() + assert t1 != t2 + assert h1 != h2 + + +# --- Token resolution --- + + +async def test_resolve_valid_token(session): + user = User(email="api-user@example.com", password_hash=hash_password("x"), role="admin") + session.add(user) + await session.flush() + + plaintext, token_hash = generate_api_token() + token = ApiToken(token_hash=token_hash, user_id=user.id) + session.add(token) + await session.flush() + + resolved = await resolve_bearer_token(session, plaintext) + assert resolved is not None + assert resolved.id == user.id + + +async def test_resolve_invalid_token(session): + resolved = await resolve_bearer_token(session, "bogus-token") + assert resolved is None + + +async def test_resolve_expired_token(session): + from datetime import timedelta + + user = User(email="expired-api@example.com", password_hash=hash_password("x")) + session.add(user) + await session.flush() + + plaintext, token_hash = generate_api_token() + token = ApiToken( + token_hash=token_hash, + user_id=user.id, + expires_at=utcnow() - timedelta(hours=1), + ) + session.add(token) + await session.flush() + + resolved = await resolve_bearer_token(session, plaintext) + assert resolved is None + + +async def test_resolve_token_disabled_user(session): + user = User( + email="disabled-api@example.com", + password_hash=hash_password("x"), + disabled_at=utcnow(), + ) + session.add(user) + await session.flush() + + plaintext, token_hash = generate_api_token() + token = ApiToken(token_hash=token_hash, user_id=user.id) + session.add(token) + await session.flush() + + resolved = await resolve_bearer_token(session, plaintext) + assert resolved is None diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py new file mode 100644 index 0000000..a926c63 --- /dev/null +++ b/tests/test_api_routes.py @@ -0,0 +1,325 @@ +"""Tests for REST API routes via httpx AsyncClient against the FastAPI app.""" + +import hashlib +from uuid import UUID, uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from httpx import ASGITransport, AsyncClient +from sqlmodel import select + +from wiregui.api.deps import get_current_api_user, get_db, require_admin +from wiregui.api.v0 import router as api_router +from wiregui.auth.api_token import generate_api_token +from wiregui.auth.passwords import hash_password +from wiregui.models.api_token import ApiToken +from wiregui.models.configuration import Configuration +from wiregui.models.device import Device +from wiregui.models.rule import Rule +from wiregui.models.user import User + + +def _build_app(session, admin_user=None, regular_user=None): + """Build a test FastAPI app with overridden dependencies.""" + test_app = FastAPI() + test_app.include_router(api_router, prefix="/api") + + async def override_get_db(): + yield session + + test_app.dependency_overrides[get_db] = override_get_db + + if admin_user: + test_app.dependency_overrides[get_current_api_user] = lambda: admin_user + test_app.dependency_overrides[require_admin] = lambda: admin_user + + return test_app + + +async def _make_admin(session) -> User: + user = User(email="api-admin@test.com", password_hash=hash_password("pw"), role="admin") + session.add(user) + await session.flush() + return user + + +async def _make_user(session, email="api-user@test.com") -> User: + user = User(email=email, password_hash=hash_password("pw"), role="unprivileged") + session.add(user) + await session.flush() + return user + + +# ========== Users API ========== + + +async def test_list_users(session): + admin = await _make_admin(session) + await _make_user(session, "user1@test.com") + await _make_user(session, "user2@test.com") + + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/v0/users/") + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 3 # admin + 2 users + + +async def test_get_user(session): + admin = await _make_admin(session) + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get(f"/api/v0/users/{admin.id}") + assert resp.status_code == 200 + assert resp.json()["email"] == "api-admin@test.com" + + +async def test_get_user_not_found(session): + admin = await _make_admin(session) + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get(f"/api/v0/users/{uuid4()}") + assert resp.status_code == 404 + + +async def test_create_user(session): + admin = await _make_admin(session) + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post("/api/v0/users/", json={ + "email": "new-api-user@test.com", + "password": "secret123", + "role": "unprivileged", + }) + assert resp.status_code == 201 + data = resp.json() + assert data["email"] == "new-api-user@test.com" + assert data["role"] == "unprivileged" + assert "id" in data + + +async def test_update_user(session): + admin = await _make_admin(session) + user = await _make_user(session) + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.put(f"/api/v0/users/{user.id}", json={ + "role": "admin", + }) + assert resp.status_code == 200 + assert resp.json()["role"] == "admin" + + +async def test_update_user_password(session): + admin = await _make_admin(session) + user = await _make_user(session) + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.put(f"/api/v0/users/{user.id}", json={ + "password": "new-password-123", + }) + assert resp.status_code == 200 + + from wiregui.auth.passwords import verify_password + refreshed = await session.get(User, user.id) + assert verify_password("new-password-123", refreshed.password_hash) + + +async def test_delete_user(session): + admin = await _make_admin(session) + user = await _make_user(session) + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.delete(f"/api/v0/users/{user.id}") + assert resp.status_code == 204 + + assert await session.get(User, user.id) is None + + +# ========== Devices API ========== + + +async def test_list_devices_admin_sees_all(session): + admin = await _make_admin(session) + user = await _make_user(session) + session.add(Device(name="d1", public_key="pk-api-d1", user_id=admin.id)) + session.add(Device(name="d2", public_key="pk-api-d2", user_id=user.id)) + await session.flush() + + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/v0/devices/") + assert resp.status_code == 200 + assert len(resp.json()) >= 2 + + +async def test_list_devices_user_sees_own(session): + admin = await _make_admin(session) + user = await _make_user(session, "own-devices@test.com") + session.add(Device(name="mine", public_key="pk-api-mine", user_id=user.id)) + session.add(Device(name="not-mine", public_key="pk-api-notmine", user_id=admin.id)) + await session.flush() + + # Override to be the regular user + test_app = _build_app(session) + test_app.dependency_overrides[get_current_api_user] = lambda: user + async with AsyncClient(transport=ASGITransport(app=test_app), base_url="http://test") as client: + resp = await client.get("/api/v0/devices/") + assert resp.status_code == 200 + names = [d["name"] for d in resp.json()] + assert "mine" in names + assert "not-mine" not in names + + +async def test_get_device(session): + admin = await _make_admin(session) + device = Device(name="detail", public_key="pk-api-detail", user_id=admin.id, ipv4="10.0.0.5") + session.add(device) + await session.flush() + + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get(f"/api/v0/devices/{device.id}") + assert resp.status_code == 200 + assert resp.json()["name"] == "detail" + assert resp.json()["ipv4"] == "10.0.0.5" + + +async def test_get_device_forbidden_for_other_user(session): + admin = await _make_admin(session) + user = await _make_user(session, "other-dev@test.com") + device = Device(name="admin-dev", public_key="pk-api-forbid", user_id=admin.id) + session.add(device) + await session.flush() + + test_app = _build_app(session) + test_app.dependency_overrides[get_current_api_user] = lambda: user + async with AsyncClient(transport=ASGITransport(app=test_app), base_url="http://test") as client: + resp = await client.get(f"/api/v0/devices/{device.id}") + assert resp.status_code == 403 + + +async def test_update_device(session): + admin = await _make_admin(session) + device = Device(name="old-name", public_key="pk-api-update", user_id=admin.id) + session.add(device) + await session.flush() + + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.put(f"/api/v0/devices/{device.id}", json={"name": "new-name"}) + assert resp.status_code == 200 + assert resp.json()["name"] == "new-name" + + +async def test_delete_device(session): + admin = await _make_admin(session) + device = Device(name="to-delete", public_key="pk-api-del", user_id=admin.id) + session.add(device) + await session.flush() + did = device.id + + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.delete(f"/api/v0/devices/{did}") + assert resp.status_code == 204 + + assert await session.get(Device, did) is None + + +# ========== Rules API ========== + + +async def test_list_rules(session): + admin = await _make_admin(session) + session.add(Rule(action="accept", destination="10.0.0.0/8")) + session.add(Rule(action="drop", destination="192.168.0.0/16", user_id=admin.id)) + await session.flush() + + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/v0/rules/") + assert resp.status_code == 200 + assert len(resp.json()) >= 2 + + +async def test_create_rule(session): + admin = await _make_admin(session) + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post("/api/v0/rules/", json={ + "action": "accept", + "destination": "172.16.0.0/12", + "port_type": "tcp", + "port_range": "443", + }) + assert resp.status_code == 201 + data = resp.json() + assert data["action"] == "accept" + assert data["destination"] == "172.16.0.0/12" + assert data["port_type"] == "tcp" + assert data["port_range"] == "443" + + +async def test_update_rule(session): + admin = await _make_admin(session) + rule = Rule(action="accept", destination="10.0.0.0/8") + session.add(rule) + await session.flush() + + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.put(f"/api/v0/rules/{rule.id}", json={"action": "drop"}) + assert resp.status_code == 200 + assert resp.json()["action"] == "drop" + + +async def test_delete_rule(session): + admin = await _make_admin(session) + rule = Rule(action="drop", destination="0.0.0.0/0") + session.add(rule) + await session.flush() + rid = rule.id + + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.delete(f"/api/v0/rules/{rid}") + assert resp.status_code == 204 + + assert await session.get(Rule, rid) is None + + +# ========== Configuration API ========== + + +async def test_get_configuration_auto_creates(session): + admin = await _make_admin(session) + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/v0/configuration/") + assert resp.status_code == 200 + data = resp.json() + assert data["default_client_mtu"] == 1280 + assert data["local_auth_enabled"] is True + + +async def test_update_configuration(session): + admin = await _make_admin(session) + # Pre-create config + config = Configuration() + session.add(config) + await session.flush() + + app = _build_app(session, admin_user=admin) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.put("/api/v0/configuration/", json={ + "default_client_mtu": 1400, + "vpn_session_duration": 3600, + "default_client_dns": ["8.8.8.8"], + }) + assert resp.status_code == 200 + data = resp.json() + assert data["default_client_mtu"] == 1400 + assert data["vpn_session_duration"] == 3600 + assert data["default_client_dns"] == ["8.8.8.8"] diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..08f35dc --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,98 @@ +"""Tests for authentication modules.""" + +from sqlmodel import select + +from wiregui.auth.jwt import create_access_token, decode_access_token +from wiregui.auth.passwords import hash_password, verify_password +from wiregui.auth.seed import seed_admin +from wiregui.models.user import User + + +# --- Password hashing --- + + +def test_hash_and_verify(): + hashed = hash_password("my-secret") + assert verify_password("my-secret", hashed) is True + + +def test_verify_wrong_password(): + hashed = hash_password("correct") + assert verify_password("wrong", hashed) is False + + +def test_hash_is_not_plaintext(): + hashed = hash_password("plaintext") + assert hashed != "plaintext" + assert hashed.startswith("$2b$") + + +# --- JWT --- + + +def test_create_and_decode_token(): + token = create_access_token(user_id="user-123", role="admin") + payload = decode_access_token(token) + assert payload is not None + assert payload["sub"] == "user-123" + assert payload["role"] == "admin" + assert "exp" in payload + + +def test_decode_invalid_token(): + assert decode_access_token("garbage.token.value") is None + + +def test_decode_tampered_token(): + token = create_access_token(user_id="user-123", role="admin") + tampered = token[:-4] + "XXXX" + assert decode_access_token(tampered) is None + + +# --- Admin seed --- + + +async def test_seed_admin_creates_user(session, monkeypatch): + """seed_admin should create an admin when no users exist.""" + # Patch async_session to use our test session + from unittest.mock import AsyncMock + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.auth.seed.async_session", mock_session) + monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), { + "admin_email": "seed-test@example.com", + "admin_password": "seed-pass-123", + })()) + + await seed_admin() + + result = await session.execute(select(User).where(User.email == "seed-test@example.com")) + admin = result.scalar_one() + assert admin.role == "admin" + assert verify_password("seed-pass-123", admin.password_hash) + + +async def test_seed_admin_skips_when_users_exist(session, monkeypatch): + """seed_admin should not create a second admin if users already exist.""" + from contextlib import asynccontextmanager + + existing = User(email="existing@example.com", role="unprivileged") + session.add(existing) + await session.flush() + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.auth.seed.async_session", mock_session) + + await seed_admin() + + result = await session.execute(select(User)) + users = result.scalars().all() + assert len(users) == 1 + assert users[0].email == "existing@example.com" diff --git a/tests/test_auth_extended.py b/tests/test_auth_extended.py new file mode 100644 index 0000000..f6f296b --- /dev/null +++ b/tests/test_auth_extended.py @@ -0,0 +1,226 @@ +"""Extended auth tests — OIDC registration, WebAuthn options, session edge cases.""" + +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +from wiregui.auth.passwords import hash_password +from wiregui.auth.session import authenticate_user +from wiregui.models.user import User +from wiregui.utils.time import utcnow + + +# ========== Session / authenticate_user edge cases ========== + + +async def test_authenticate_user_no_password_hash(session, monkeypatch): + """Users without a password (OIDC-only) should not authenticate via password.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.auth.session.async_session", mock_session) + + user = User(email="no-pw@test.com", password_hash=None) + session.add(user) + await session.flush() + + result = await authenticate_user("no-pw@test.com", "anything") + assert result is None + + +async def test_authenticate_user_disabled(session, monkeypatch): + """Disabled users should not authenticate.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.auth.session.async_session", mock_session) + + user = User(email="disabled-auth@test.com", password_hash=hash_password("pw"), disabled_at=utcnow()) + session.add(user) + await session.flush() + + result = await authenticate_user("disabled-auth@test.com", "pw") + assert result is None + + +async def test_authenticate_user_nonexistent(session, monkeypatch): + """Nonexistent email should return None.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.auth.session.async_session", mock_session) + + result = await authenticate_user("ghost@nowhere.com", "pw") + assert result is None + + +# ========== OIDC provider registration ========== + + +async def test_register_providers_from_config(session, monkeypatch): + """register_providers should register configured OIDC providers with authlib.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.auth.oidc.async_session", mock_session) + + from wiregui.models.configuration import Configuration + config = Configuration(openid_connect_providers=[ + { + "id": "test-reg", + "label": "Test", + "scope": "openid email", + "client_id": "cid", + "client_secret": "cs", + "discovery_document_uri": "https://idp.test/.well-known/openid-configuration", + } + ]) + session.add(config) + await session.flush() + + with patch("wiregui.auth.oidc.oauth") as mock_oauth: + from wiregui.auth.oidc import register_providers + await register_providers() + mock_oauth.register.assert_called_once() + call_kwargs = mock_oauth.register.call_args[1] + assert call_kwargs["name"] == "test-reg" + assert call_kwargs["client_id"] == "cid" + + +async def test_get_client_unknown_provider(): + """get_client should raise for unregistered providers.""" + import pytest + from wiregui.auth.oidc import get_client + with pytest.raises(ValueError, match="not registered"): + get_client("nonexistent-provider-xyz") + + +# ========== WebAuthn options ========== + + +def test_webauthn_registration_options(monkeypatch): + """create_registration_options should return valid options and challenge.""" + monkeypatch.setattr("wiregui.auth.webauthn.get_settings", lambda: type("S", (), { + "external_url": "https://vpn.example.com", + })()) + + from wiregui.auth.webauthn import create_registration_options + user_id = uuid4() + result = create_registration_options(user_id, "user@example.com") + + assert "options_json" in result + assert "challenge" in result + assert len(result["challenge"]) > 10 + assert "user@example.com" in result["options_json"] + + +def test_webauthn_registration_options_with_excludes(monkeypatch): + """Existing credentials should be excluded from registration options.""" + monkeypatch.setattr("wiregui.auth.webauthn.get_settings", lambda: type("S", (), { + "external_url": "https://vpn.example.com", + })()) + + from wiregui.auth.webauthn import create_registration_options + existing = [{"credential_id": "AQIDBA"}] # base64url of bytes [1,2,3,4] + result = create_registration_options(uuid4(), "user@example.com", existing) + assert "options_json" in result + + +def test_webauthn_authentication_options(monkeypatch): + """create_authentication_options should accept credential descriptors.""" + monkeypatch.setattr("wiregui.auth.webauthn.get_settings", lambda: type("S", (), { + "external_url": "https://vpn.example.com", + })()) + + from wiregui.auth.webauthn import create_authentication_options + credentials = [{"credential_id": "AQIDBA"}] + result = create_authentication_options(credentials) + assert "options_json" in result + assert "challenge" in result + + +# ========== Events — rule update/delete with rebuild ========== + + +@patch("wiregui.services.events.get_settings") +@patch("wiregui.services.events.firewall") +async def test_on_rule_updated_triggers_rebuild(mock_fw, mock_settings): + """on_rule_updated should rebuild the user's firewall chain.""" + mock_settings.return_value.wg_enabled = True + mock_fw.rebuild_all_rules = AsyncMock() + + from wiregui.models.rule import Rule + from wiregui.services.events import on_rule_updated + + # Need to mock the DB call inside _rebuild_user_chain + with patch("wiregui.services.events.async_session") as mock_session_factory: + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + # Mock the select results + mock_rules_result = MagicMock() + mock_rules_result.scalars.return_value.all.return_value = [] + mock_devices_result = MagicMock() + mock_devices_result.scalars.return_value.all.return_value = [] + mock_session.execute = AsyncMock(side_effect=[mock_rules_result, mock_devices_result]) + + mock_session_factory.return_value = mock_session + + rule = Rule(action="accept", destination="10.0.0.0/8", user_id="a1b2c3d4-0000-0000-0000-000000000000") + await on_rule_updated(rule) + + mock_fw.rebuild_all_rules.assert_awaited_once() + + +@patch("wiregui.services.events.get_settings") +@patch("wiregui.services.events.firewall") +async def test_on_rule_deleted_triggers_rebuild(mock_fw, mock_settings): + """on_rule_deleted should rebuild the user's firewall chain.""" + mock_settings.return_value.wg_enabled = True + mock_fw.rebuild_all_rules = AsyncMock() + + from wiregui.models.rule import Rule + from wiregui.services.events import on_rule_deleted + + with patch("wiregui.services.events.async_session") as mock_session_factory: + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + mock_rules_result = MagicMock() + mock_rules_result.scalars.return_value.all.return_value = [] + mock_devices_result = MagicMock() + mock_devices_result.scalars.return_value.all.return_value = [] + mock_session.execute = AsyncMock(side_effect=[mock_rules_result, mock_devices_result]) + + mock_session_factory.return_value = mock_session + + rule = Rule(action="drop", destination="0.0.0.0/0", user_id="a1b2c3d4-0000-0000-0000-000000000000") + await on_rule_deleted(rule) + + mock_fw.rebuild_all_rules.assert_awaited_once() + + +@patch("wiregui.services.events.get_settings") +async def test_on_rule_deleted_skips_when_disabled(mock_settings): + """Rule events should be no-ops when WG is disabled.""" + mock_settings.return_value.wg_enabled = False + + from wiregui.models.rule import Rule + from wiregui.services.events import on_rule_deleted, on_rule_updated + + rule = Rule(action="drop", destination="0.0.0.0/0", user_id="a1b2c3d4-0000-0000-0000-000000000000") + await on_rule_updated(rule) # Should not raise + await on_rule_deleted(rule) # Should not raise diff --git a/tests/test_firewall.py b/tests/test_firewall.py new file mode 100644 index 0000000..24b04ce --- /dev/null +++ b/tests/test_firewall.py @@ -0,0 +1,40 @@ +"""Tests for firewall service — rule expression building and chain naming.""" + +from wiregui.services.firewall import _build_rule_expr, _user_chain_name + + +def test_user_chain_name(): + uid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + name = _user_chain_name(uid) + assert name == "user_a1b2c3d4e5f6" + assert len(name) <= 30 + + +def test_user_chain_name_deterministic(): + uid = "12345678-1234-1234-1234-123456789abc" + assert _user_chain_name(uid) == _user_chain_name(uid) + + +def test_build_rule_expr_ipv4_accept(): + expr = _build_rule_expr("10.0.0.0/8", "accept") + assert expr == "ip daddr 10.0.0.0/8 accept" + + +def test_build_rule_expr_ipv6_drop(): + expr = _build_rule_expr("fd00::/64", "drop") + assert expr == "ip6 daddr fd00::/64 drop" + + +def test_build_rule_expr_with_port(): + expr = _build_rule_expr("192.168.0.0/16", "accept", port_type="tcp", port_range="80-443") + assert expr == "ip daddr 192.168.0.0/16 tcp dport 80-443 accept" + + +def test_build_rule_expr_single_port(): + expr = _build_rule_expr("10.0.0.1/32", "drop", port_type="udp", port_range="53") + assert expr == "ip daddr 10.0.0.1/32 udp dport 53 drop" + + +def test_build_rule_expr_no_port(): + expr = _build_rule_expr("0.0.0.0/0", "accept", port_type=None, port_range=None) + assert expr == "ip daddr 0.0.0.0/0 accept" diff --git a/tests/test_integration_mfa.py b/tests/test_integration_mfa.py new file mode 100644 index 0000000..6a4ae62 --- /dev/null +++ b/tests/test_integration_mfa.py @@ -0,0 +1,239 @@ +"""Integration tests for MFA — full registration and authentication flows through the database.""" + +import pyotp +from sqlmodel import func, select + +from wiregui.auth.mfa import generate_totp_secret, verify_totp_code +from wiregui.auth.passwords import hash_password, verify_password +from wiregui.auth.session import authenticate_user +from wiregui.models.mfa_method import MFAMethod +from wiregui.models.user import User +from wiregui.utils.time import utcnow + + +async def test_full_totp_registration_flow(session, monkeypatch): + """End-to-end: create user → generate secret → verify code → store method → re-verify from DB.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + # Create user with password + user = User(email="mfa-flow@example.com", password_hash=hash_password("secure123")) + session.add(user) + await session.flush() + + # Step 1: Generate TOTP secret (happens in account page) + secret = generate_totp_secret() + + # Step 2: User scans QR, enters code from their authenticator + totp = pyotp.TOTP(secret) + code = totp.now() + + # Step 3: Verify the code is correct before saving + assert verify_totp_code(secret, code) is True + + # Step 4: Save the MFA method to DB + method = MFAMethod( + name="My Authenticator", + type="totp", + payload={"secret": secret}, + user_id=user.id, + ) + session.add(method) + await session.flush() + + # Step 5: Simulate future login — load method from DB and verify a fresh code + fetched_methods = (await session.execute( + select(MFAMethod).where(MFAMethod.user_id == user.id) + )).scalars().all() + + assert len(fetched_methods) == 1 + stored_secret = fetched_methods[0].payload["secret"] + fresh_code = pyotp.TOTP(stored_secret).now() + assert verify_totp_code(stored_secret, fresh_code) is True + + +async def test_mfa_blocks_login_without_code(session, monkeypatch): + """User with MFA should not be fully authenticated without completing MFA challenge.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.auth.session.async_session", mock_session) + + # Create user with MFA + user = User(email="mfa-block@example.com", password_hash=hash_password("password1")) + session.add(user) + await session.flush() + + secret = generate_totp_secret() + method = MFAMethod(name="Phone", type="totp", payload={"secret": secret}, user_id=user.id) + session.add(method) + await session.flush() + + # Password auth succeeds + authed_user = await authenticate_user("mfa-block@example.com", "password1") + assert authed_user is not None + + # But MFA methods exist — login page would redirect to /mfa instead of completing login + mfa_methods = (await session.execute( + select(MFAMethod).where(MFAMethod.user_id == authed_user.id) + )).scalars().all() + assert len(mfa_methods) > 0 # Login flow would check this and redirect to /mfa + + +async def test_mfa_wrong_code_rejected(session): + """Wrong TOTP code should be rejected even if method is valid.""" + user = User(email="mfa-wrong@example.com", password_hash=hash_password("pw")) + session.add(user) + await session.flush() + + secret = generate_totp_secret() + method = MFAMethod(name="Auth", type="totp", payload={"secret": secret}, user_id=user.id) + session.add(method) + await session.flush() + + # Load from DB and try wrong code + fetched = (await session.execute( + select(MFAMethod).where(MFAMethod.user_id == user.id) + )).scalar_one() + + assert verify_totp_code(fetched.payload["secret"], "000000") is False + assert verify_totp_code(fetched.payload["secret"], "123456") is False + + +async def test_mfa_multiple_methods_any_valid_code_works(session): + """If user has multiple TOTP methods, a valid code from any should work.""" + user = User(email="mfa-multi@example.com") + session.add(user) + await session.flush() + + secret1 = generate_totp_secret() + secret2 = generate_totp_secret() + + session.add(MFAMethod(name="Phone", type="totp", payload={"secret": secret1}, user_id=user.id)) + session.add(MFAMethod(name="Backup", type="totp", payload={"secret": secret2}, user_id=user.id)) + await session.flush() + + methods = (await session.execute( + select(MFAMethod).where(MFAMethod.user_id == user.id) + )).scalars().all() + + # Code from method 1 should verify against method 1's secret + code1 = pyotp.TOTP(secret1).now() + verified = False + for m in methods: + if verify_totp_code(m.payload["secret"], code1): + verified = True + break + assert verified is True + + # Code from method 2 should also work + code2 = pyotp.TOTP(secret2).now() + verified2 = False + for m in methods: + if verify_totp_code(m.payload["secret"], code2): + verified2 = True + break + assert verified2 is True + + +async def test_mfa_method_last_used_tracking(session): + """Verifying MFA should update last_used_at timestamp.""" + user = User(email="mfa-tracking@example.com") + session.add(user) + await session.flush() + + secret = generate_totp_secret() + method = MFAMethod(name="Auth", type="totp", payload={"secret": secret}, user_id=user.id) + session.add(method) + await session.flush() + + assert method.last_used_at is None + + # Simulate successful verification and update + code = pyotp.TOTP(secret).now() + assert verify_totp_code(secret, code) is True + + method.last_used_at = utcnow() + session.add(method) + await session.flush() + + fetched = await session.get(MFAMethod, method.id) + assert fetched.last_used_at is not None + + +async def test_mfa_delete_method_allows_login_without_mfa(session, monkeypatch): + """After removing all MFA methods, user should not be redirected to MFA challenge.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.auth.session.async_session", mock_session) + + user = User(email="mfa-remove@example.com", password_hash=hash_password("pw")) + session.add(user) + await session.flush() + + secret = generate_totp_secret() + method = MFAMethod(name="Temp", type="totp", payload={"secret": secret}, user_id=user.id) + session.add(method) + await session.flush() + + # MFA exists + count = (await session.execute( + select(func.count()).select_from(MFAMethod).where(MFAMethod.user_id == user.id) + )).scalar() + assert count == 1 + + # Delete it + await session.delete(method) + await session.flush() + + count = (await session.execute( + select(func.count()).select_from(MFAMethod).where(MFAMethod.user_id == user.id) + )).scalar() + assert count == 0 + + # Password auth still works + authed = await authenticate_user("mfa-remove@example.com", "pw") + assert authed is not None + + # No MFA methods — login flow would skip MFA challenge + mfa_check = (await session.execute( + select(MFAMethod).where(MFAMethod.user_id == authed.id) + )).scalars().all() + assert len(mfa_check) == 0 + + +async def test_disabled_user_with_mfa_cannot_login(session, monkeypatch): + """Disabled user should be rejected at password stage, never reaching MFA.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.auth.session.async_session", mock_session) + + user = User( + email="mfa-disabled@example.com", + password_hash=hash_password("pw"), + disabled_at=utcnow(), + ) + session.add(user) + await session.flush() + + secret = generate_totp_secret() + session.add(MFAMethod(name="Auth", type="totp", payload={"secret": secret}, user_id=user.id)) + await session.flush() + + # Password auth rejects disabled user before MFA is ever checked + result = await authenticate_user("mfa-disabled@example.com", "pw") + assert result is None diff --git a/tests/test_integration_oidc.py b/tests/test_integration_oidc.py new file mode 100644 index 0000000..3ecd07f --- /dev/null +++ b/tests/test_integration_oidc.py @@ -0,0 +1,309 @@ +"""Integration tests for OIDC — mock provider endpoints, test full auth code flow.""" + +import json +import time +from unittest.mock import patch +from uuid import uuid4 + +import respx +from httpx import Response +from jose import jwt +from sqlmodel import select + +from wiregui.auth.oidc import get_provider_config, load_providers, oauth, register_providers +from wiregui.config import get_settings +from wiregui.models.configuration import Configuration +from wiregui.models.oidc_connection import OIDCConnection +from wiregui.models.user import User + + +# --- Helper to create a fake OIDC provider config in the DB --- + + +async def _setup_oidc_config(session) -> Configuration: + """Insert a Configuration with a test OIDC provider.""" + config = Configuration( + openid_connect_providers=[ + { + "id": "test-idp", + "label": "Test IdP", + "scope": "openid email profile", + "response_type": "code", + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "discovery_document_uri": "https://idp.example.com/.well-known/openid-configuration", + "auto_create_users": True, + } + ], + ) + session.add(config) + await session.commit() + return config + + +def _mock_discovery(): + """Mock OIDC discovery document response.""" + return { + "issuer": "https://idp.example.com", + "authorization_endpoint": "https://idp.example.com/authorize", + "token_endpoint": "https://idp.example.com/token", + "userinfo_endpoint": "https://idp.example.com/userinfo", + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + } + + +def _mock_token_response(email: str = "oidc-user@example.com"): + """Mock OIDC token endpoint response with ID token.""" + now = int(time.time()) + id_token_payload = { + "iss": "https://idp.example.com", + "sub": "oidc-subject-123", + "aud": "test-client-id", + "email": email, + "name": "OIDC User", + "iat": now, + "exp": now + 3600, + "nonce": "test-nonce", + } + # Sign with a simple secret (in real life this would be RSA) + id_token = jwt.encode(id_token_payload, "fake-secret", algorithm="HS256") + + return { + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "mock-refresh-token", + "id_token": id_token, + } + + +# --- Provider config loading --- + + +async def test_load_providers_from_config(session, monkeypatch): + """Providers should be loaded from the Configuration table.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.auth.oidc.async_session", mock_session) + + await _setup_oidc_config(session) + + providers = await load_providers() + assert len(providers) == 1 + assert providers[0]["id"] == "test-idp" + assert providers[0]["client_id"] == "test-client-id" + + +async def test_load_providers_empty_when_no_config(session, monkeypatch): + """Should return empty list when no Configuration exists.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.auth.oidc.async_session", mock_session) + + providers = await load_providers() + assert providers == [] + + +async def test_get_provider_config_by_id(session, monkeypatch): + """Should find a specific provider by ID.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.auth.oidc.async_session", mock_session) + + await _setup_oidc_config(session) + + config = await get_provider_config("test-idp") + assert config is not None + assert config["label"] == "Test IdP" + + config_missing = await get_provider_config("nonexistent") + assert config_missing is None + + +# --- OIDC connection storage --- + + +async def test_oidc_connection_created_on_login(session): + """Simulates what the callback route does: create user + OIDC connection.""" + user = User(email="oidc-new@example.com", role="unprivileged") + session.add(user) + await session.flush() + + token_data = _mock_token_response("oidc-new@example.com") + conn = OIDCConnection( + provider="test-idp", + refresh_token=token_data["refresh_token"], + refresh_response=token_data, + user_id=user.id, + ) + session.add(conn) + await session.flush() + + # Verify it was stored + fetched = (await session.execute( + select(OIDCConnection).where(OIDCConnection.user_id == user.id) + )).scalar_one() + assert fetched.provider == "test-idp" + assert fetched.refresh_token == "mock-refresh-token" + assert fetched.refresh_response["access_token"] == "mock-access-token" + + +async def test_oidc_connection_updated_on_re_login(session): + """Re-login should update the existing OIDC connection, not create a duplicate.""" + user = User(email="oidc-relogin@example.com") + session.add(user) + await session.flush() + + # First login + conn = OIDCConnection( + provider="test-idp", + refresh_token="old-refresh-token", + user_id=user.id, + ) + session.add(conn) + await session.flush() + + # Re-login — update existing connection (as the callback route does) + existing = (await session.execute( + select(OIDCConnection).where( + OIDCConnection.user_id == user.id, + OIDCConnection.provider == "test-idp", + ) + )).scalar_one() + + existing.refresh_token = "new-refresh-token" + from wiregui.utils.time import utcnow + existing.refreshed_at = utcnow() + session.add(existing) + await session.flush() + + # Should still be one connection + from sqlmodel import func + count = (await session.execute( + select(func.count()).select_from(OIDCConnection).where(OIDCConnection.user_id == user.id) + )).scalar() + assert count == 1 + + fetched = (await session.execute( + select(OIDCConnection).where(OIDCConnection.user_id == user.id) + )).scalar_one() + assert fetched.refresh_token == "new-refresh-token" + + +async def test_oidc_auto_create_user(session): + """When auto_create_users is True, a new user should be created from OIDC email.""" + email = "auto-created@example.com" + + # Verify user doesn't exist + existing = (await session.execute(select(User).where(User.email == email))).scalar_one_or_none() + assert existing is None + + # Simulate what callback does with auto_create + user = User(email=email, role="unprivileged") + session.add(user) + await session.flush() + + from wiregui.utils.time import utcnow + user.last_signed_in_at = utcnow() + user.last_signed_in_method = "oidc:test-idp" + session.add(user) + await session.flush() + + created = (await session.execute(select(User).where(User.email == email))).scalar_one() + assert created.role == "unprivileged" + assert created.last_signed_in_method == "oidc:test-idp" + + +async def test_oidc_disabled_user_rejected(session): + """Disabled users should not be logged in via OIDC.""" + from wiregui.utils.time import utcnow + + user = User(email="oidc-disabled@example.com", disabled_at=utcnow()) + session.add(user) + await session.flush() + + # The callback route checks disabled_at before creating session + assert user.disabled_at is not None # Would redirect to /login + + +async def test_oidc_user_without_auto_create_rejected(session): + """When auto_create is False and user doesn't exist, login should fail.""" + email = "no-auto-create@example.com" + + existing = (await session.execute(select(User).where(User.email == email))).scalar_one_or_none() + assert existing is None + + # The callback route checks auto_create_users from provider config + # With auto_create=False and no existing user, it would redirect to /login + # This verifies the precondition + + +# --- OIDC refresh token flow --- + + +async def test_oidc_refresh_stores_new_token(session): + """Simulates a successful token refresh updating the connection.""" + user = User(email="oidc-refresh-test@example.com") + session.add(user) + await session.flush() + + conn = OIDCConnection( + provider="test-idp", + refresh_token="old-refresh", + user_id=user.id, + ) + session.add(conn) + await session.flush() + + # Simulate refresh result + new_token = { + "access_token": "new-access", + "refresh_token": "new-refresh", + "expires_in": 3600, + } + + conn.refresh_token = new_token.get("refresh_token", conn.refresh_token) + conn.refresh_response = new_token + from wiregui.utils.time import utcnow + conn.refreshed_at = utcnow() + session.add(conn) + await session.flush() + + fetched = await session.get(OIDCConnection, conn.id) + assert fetched.refresh_token == "new-refresh" + assert fetched.refresh_response["access_token"] == "new-access" + assert fetched.refreshed_at is not None + + +async def test_oidc_multiple_providers_per_user(session): + """User can have connections to multiple OIDC providers.""" + user = User(email="multi-provider@example.com") + session.add(user) + await session.flush() + + for provider in ["google", "okta", "azure-ad"]: + session.add(OIDCConnection( + provider=provider, + refresh_token=f"token-{provider}", + user_id=user.id, + )) + await session.flush() + + conns = (await session.execute( + select(OIDCConnection).where(OIDCConnection.user_id == user.id).order_by(OIDCConnection.provider) + )).scalars().all() + + assert len(conns) == 3 + assert [c.provider for c in conns] == ["azure-ad", "google", "okta"] diff --git a/tests/test_magic_link.py b/tests/test_magic_link.py new file mode 100644 index 0000000..0975c54 --- /dev/null +++ b/tests/test_magic_link.py @@ -0,0 +1,58 @@ +"""Tests for magic link authentication flow.""" + +from datetime import timedelta + +from wiregui.auth.jwt import create_access_token, decode_access_token +from wiregui.auth.passwords import hash_password +from wiregui.models.user import User + + +def test_magic_link_token_creation(): + """Magic link token should be a valid JWT with short expiry.""" + token = create_access_token( + user_id="user-123", + role="unprivileged", + expires_delta=timedelta(minutes=15), + ) + payload = decode_access_token(token) + assert payload is not None + assert payload["sub"] == "user-123" + assert payload["role"] == "unprivileged" + + +def test_magic_link_token_expired(): + """Expired magic link token should be rejected.""" + token = create_access_token( + user_id="user-123", + role="admin", + expires_delta=timedelta(minutes=-1), # Already expired + ) + payload = decode_access_token(token) + assert payload is None + + +def test_magic_link_token_wrong_user(): + """Token should only be valid for the intended user.""" + token = create_access_token(user_id="user-A", role="admin") + payload = decode_access_token(token) + assert payload["sub"] == "user-A" + # Caller is responsible for checking sub matches the URL user_id + + +async def test_magic_link_disabled_user_rejected(session): + """Disabled users should not be able to use magic links.""" + from wiregui.utils.time import utcnow + + user = User( + email="disabled-magic@example.com", + password_hash=hash_password("pw"), + disabled_at=utcnow(), + ) + session.add(user) + await session.flush() + + # The token would be valid but the page handler checks disabled_at + token = create_access_token(user_id=str(user.id), role="unprivileged") + payload = decode_access_token(token) + assert payload is not None # Token itself is valid + assert user.disabled_at is not None # But user is disabled — handler would reject diff --git a/tests/test_mfa.py b/tests/test_mfa.py new file mode 100644 index 0000000..48b4eee --- /dev/null +++ b/tests/test_mfa.py @@ -0,0 +1,127 @@ +"""Tests for TOTP MFA functionality.""" + +import pyotp + +from wiregui.auth.mfa import ( + generate_totp_qr_svg, + generate_totp_secret, + get_totp_uri, + verify_totp_code, +) +from wiregui.models.mfa_method import MFAMethod +from wiregui.models.user import User + + +# --- TOTP secret generation --- + + +def test_generate_secret(): + secret = generate_totp_secret() + assert len(secret) == 32 # base32 encoded + assert secret.isalpha() or any(c.isdigit() for c in secret) + + +def test_generate_secret_unique(): + s1 = generate_totp_secret() + s2 = generate_totp_secret() + assert s1 != s2 + + +# --- TOTP URI --- + + +def test_get_totp_uri(): + uri = get_totp_uri("JBSWY3DPEHPK3PXP", "user@example.com") + assert uri.startswith("otpauth://totp/") + assert "user%40example.com" in uri or "user@example.com" in uri + assert "secret=JBSWY3DPEHPK3PXP" in uri + assert "issuer=WireGUI" in uri + + +def test_get_totp_uri_custom_issuer(): + uri = get_totp_uri("SECRET", "test@test.com", issuer="MyVPN") + assert "issuer=MyVPN" in uri + + +# --- TOTP verification --- + + +def test_verify_valid_code(): + secret = generate_totp_secret() + totp = pyotp.TOTP(secret) + code = totp.now() + assert verify_totp_code(secret, code) is True + + +def test_verify_invalid_code(): + secret = generate_totp_secret() + assert verify_totp_code(secret, "000000") is False + + +def test_verify_wrong_secret(): + secret1 = generate_totp_secret() + secret2 = generate_totp_secret() + code = pyotp.TOTP(secret1).now() + assert verify_totp_code(secret2, code) is False + + +def test_verify_empty_code(): + secret = generate_totp_secret() + assert verify_totp_code(secret, "") is False + + +# --- QR code generation --- + + +def test_generate_qr_svg(): + uri = get_totp_uri("SECRET", "test@test.com") + svg = generate_totp_qr_svg(uri) + assert "" in svg + + +# --- MFA method model integration --- + + +async def test_create_totp_method(session): + user = User(email="mfa-test@example.com") + session.add(user) + await session.flush() + + secret = generate_totp_secret() + method = MFAMethod( + name="My Phone", + type="totp", + payload={"secret": secret}, + user_id=user.id, + ) + session.add(method) + await session.flush() + + from sqlmodel import select + fetched = (await session.execute( + select(MFAMethod).where(MFAMethod.user_id == user.id) + )).scalar_one() + + assert fetched.name == "My Phone" + assert fetched.type == "totp" + stored_secret = fetched.payload["secret"] + code = pyotp.TOTP(stored_secret).now() + assert verify_totp_code(stored_secret, code) is True + + +async def test_user_multiple_mfa_methods(session): + user = User(email="multi-mfa@example.com") + session.add(user) + await session.flush() + + m1 = MFAMethod(name="Phone", type="totp", payload={"secret": generate_totp_secret()}, user_id=user.id) + m2 = MFAMethod(name="Backup", type="totp", payload={"secret": generate_totp_secret()}, user_id=user.id) + session.add_all([m1, m2]) + await session.flush() + + from sqlmodel import select, func + count = (await session.execute( + select(func.count()).select_from(MFAMethod).where(MFAMethod.user_id == user.id) + )).scalar() + assert count == 2 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..ffeaa67 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,168 @@ +"""Tests for SQLModel table definitions.""" + +import pytest # noqa: F401 — needed for pytest.raises +from sqlmodel import select + +from wiregui.models.api_token import ApiToken +from wiregui.models.configuration import Configuration +from wiregui.models.connectivity_check import ConnectivityCheck +from wiregui.models.device import Device +from wiregui.models.mfa_method import MFAMethod +from wiregui.models.oidc_connection import OIDCConnection +from wiregui.models.rule import Rule +from wiregui.models.user import User + + +async def test_create_user(session): + user = User(email="alice@example.com", role="admin") + session.add(user) + await session.flush() + + result = await session.execute(select(User).where(User.email == "alice@example.com")) + fetched = result.scalar_one() + assert fetched.id == user.id + assert fetched.role == "admin" + assert fetched.disabled_at is None + + +async def test_create_device_with_user(session): + user = User(email="bob@example.com") + session.add(user) + await session.flush() + + device = Device( + name="laptop", + public_key="pk-test-device-001", + user_id=user.id, + ) + session.add(device) + await session.flush() + + result = await session.execute(select(Device).where(Device.public_key == "pk-test-device-001")) + fetched = result.scalar_one() + assert fetched.name == "laptop" + assert fetched.user_id == user.id + assert fetched.use_default_dns is True + assert fetched.use_default_allowed_ips is True + assert fetched.rx_bytes is None + + +async def test_device_unique_public_key(session): + user = User(email="carol@example.com") + session.add(user) + await session.flush() + + d1 = Device(name="d1", public_key="duplicate-key", user_id=user.id) + session.add(d1) + await session.flush() + + d2 = Device(name="d2", public_key="duplicate-key", user_id=user.id) + session.add(d2) + with pytest.raises(Exception): # IntegrityError + await session.flush() + + +async def test_create_rule(session): + user = User(email="dave@example.com") + session.add(user) + await session.flush() + + rule = Rule(action="accept", destination="10.0.0.0/8", user_id=user.id) + session.add(rule) + await session.flush() + + result = await session.execute(select(Rule).where(Rule.user_id == user.id)) + fetched = result.scalar_one() + assert fetched.action == "accept" + assert fetched.destination == "10.0.0.0/8" + assert fetched.port_type is None + assert fetched.port_range is None + + +async def test_create_rule_with_port(session): + rule = Rule( + action="drop", + destination="192.168.0.0/16", + port_type="tcp", + port_range="80-443", + ) + session.add(rule) + await session.flush() + + fetched = (await session.execute(select(Rule).where(Rule.id == rule.id))).scalar_one() + assert fetched.port_type == "tcp" + assert fetched.port_range == "80-443" + assert fetched.user_id is None # global rule + + +async def test_create_mfa_method(session): + user = User(email="eve@example.com") + session.add(user) + await session.flush() + + mfa = MFAMethod( + name="My Authenticator", + type="totp", + payload={"secret": "JBSWY3DPEHPK3PXP"}, + user_id=user.id, + ) + session.add(mfa) + await session.flush() + + fetched = (await session.execute(select(MFAMethod).where(MFAMethod.user_id == user.id))).scalar_one() + assert fetched.type == "totp" + assert fetched.payload["secret"] == "JBSWY3DPEHPK3PXP" + + +async def test_create_oidc_connection(session): + user = User(email="frank@example.com") + session.add(user) + await session.flush() + + conn = OIDCConnection(provider="google", refresh_token="tok_abc", user_id=user.id) + session.add(conn) + await session.flush() + + fetched = (await session.execute(select(OIDCConnection).where(OIDCConnection.user_id == user.id))).scalar_one() + assert fetched.provider == "google" + assert fetched.refresh_token == "tok_abc" + + +async def test_create_api_token(session): + user = User(email="grace@example.com") + session.add(user) + await session.flush() + + token = ApiToken(token_hash="sha256_fake_hash", user_id=user.id) + session.add(token) + await session.flush() + + fetched = (await session.execute(select(ApiToken).where(ApiToken.user_id == user.id))).scalar_one() + assert fetched.token_hash == "sha256_fake_hash" + assert fetched.expires_at is None + + +async def test_create_connectivity_check(session): + check = ConnectivityCheck(url="https://example.com", response_code=200) + session.add(check) + await session.flush() + + fetched = (await session.execute(select(ConnectivityCheck).where(ConnectivityCheck.id == check.id))).scalar_one() + assert fetched.response_code == 200 + + +async def test_configuration_defaults(session): + config = Configuration() + session.add(config) + await session.flush() + + fetched = (await session.execute(select(Configuration).where(Configuration.id == config.id))).scalar_one() + assert fetched.allow_unprivileged_device_management is True + assert fetched.local_auth_enabled is True + assert fetched.default_client_mtu == 1280 + assert fetched.default_client_persistent_keepalive == 25 + assert fetched.default_client_dns == ["1.1.1.1", "1.0.0.1"] + assert fetched.default_client_allowed_ips == ["0.0.0.0/0", "::/0"] + assert fetched.vpn_session_duration == 0 + assert fetched.openid_connect_providers == [] + assert fetched.saml_identity_providers == [] diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..2b764a3 --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,89 @@ +"""Tests for the notification service.""" + +from wiregui.services import notifications + + +def setup_function(): + """Clear notifications before each test.""" + notifications.clear_all() + + +def test_add_notification(): + n = notifications.add("info", "Test message") + assert n.severity == "info" + assert n.message == "Test message" + assert n.user is None + assert n.id is not None + assert n.timestamp is not None + + +def test_add_notification_with_user(): + n = notifications.add("error", "Something broke", user="admin@example.com") + assert n.user == "admin@example.com" + assert n.severity == "error" + + +def test_current_returns_newest_first(): + notifications.add("info", "First") + notifications.add("warning", "Second") + notifications.add("error", "Third") + + current = notifications.current() + assert len(current) == 3 + assert current[0].message == "Third" + assert current[1].message == "Second" + assert current[2].message == "First" + + +def test_count(): + assert notifications.count() == 0 + notifications.add("info", "One") + notifications.add("info", "Two") + assert notifications.count() == 2 + + +def test_clear_specific(): + n1 = notifications.add("info", "Keep this") + n2 = notifications.add("error", "Remove this") + + notifications.clear(n2.id) + current = notifications.current() + assert len(current) == 1 + assert current[0].id == n1.id + + +def test_clear_nonexistent_id_is_noop(): + notifications.add("info", "Test") + notifications.clear("nonexistent-id") + assert notifications.count() == 1 + + +def test_clear_all(): + notifications.add("info", "One") + notifications.add("info", "Two") + notifications.add("info", "Three") + assert notifications.count() == 3 + + notifications.clear_all() + assert notifications.count() == 0 + assert notifications.current() == [] + + +def test_to_dict(): + n = notifications.add("warning", "Test dict", user="someone@example.com") + d = n.to_dict() + assert d["severity"] == "warning" + assert d["message"] == "Test dict" + assert d["user"] == "someone@example.com" + assert "id" in d + assert "timestamp" in d + + +def test_max_notifications(): + """Deque should cap at MAX_NOTIFICATIONS.""" + for i in range(notifications.MAX_NOTIFICATIONS + 10): + notifications.add("info", f"Notification {i}") + + assert notifications.count() == notifications.MAX_NOTIFICATIONS + # Newest should be the last one added + assert notifications.current()[0].message == f"Notification {notifications.MAX_NOTIFICATIONS + 9}" diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..f0cf173 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,124 @@ +"""Tests for services — WireGuard and events.""" + +from unittest.mock import AsyncMock, patch + +from wiregui.models.device import Device +from wiregui.models.rule import Rule +from wiregui.services.events import on_device_created, on_device_deleted, on_device_updated, on_rule_created + + +def _make_device(**kwargs) -> Device: + defaults = dict( + name="test", + public_key="pk-test", + preshared_key="psk-test", + ipv4="10.3.2.5", + ipv6="fd00::3:2:5", + user_id="00000000-0000-0000-0000-000000000000", + ) + defaults.update(kwargs) + return Device(**defaults) + + +# --- Events (with WG enabled) --- + + +@patch("wiregui.services.events.get_settings") +@patch("wiregui.services.events.firewall") +@patch("wiregui.services.events.wireguard") +async def test_on_device_created_calls_add_peer(mock_wg, mock_fw, mock_settings): + mock_settings.return_value.wg_enabled = True + mock_wg.add_peer = AsyncMock() + mock_fw.add_device_jump_rule = AsyncMock() + + device = _make_device() + await on_device_created(device) + + mock_wg.add_peer.assert_awaited_once_with( + public_key="pk-test", + allowed_ips=["10.3.2.5/32", "fd00::3:2:5/128"], + preshared_key="psk-test", + ) + mock_fw.add_device_jump_rule.assert_awaited_once() + + +@patch("wiregui.services.events.get_settings") +@patch("wiregui.services.events.wireguard") +async def test_on_device_deleted_calls_remove_peer(mock_wg, mock_settings): + mock_settings.return_value.wg_enabled = True + mock_wg.remove_peer = AsyncMock() + + device = _make_device() + await on_device_deleted(device) + + mock_wg.remove_peer.assert_awaited_once_with(public_key="pk-test") + + +@patch("wiregui.services.events.get_settings") +@patch("wiregui.services.events.wireguard") +async def test_on_device_updated_calls_add_peer(mock_wg, mock_settings): + mock_settings.return_value.wg_enabled = True + mock_wg.add_peer = AsyncMock() + + device = _make_device() + await on_device_updated(device) + + mock_wg.add_peer.assert_awaited_once() + + +# --- Events (WG disabled) --- + + +@patch("wiregui.services.events.get_settings") +@patch("wiregui.services.events.wireguard") +async def test_events_skip_when_wg_disabled(mock_wg, mock_settings): + mock_settings.return_value.wg_enabled = False + mock_wg.add_peer = AsyncMock() + mock_wg.remove_peer = AsyncMock() + + device = _make_device() + await on_device_created(device) + await on_device_deleted(device) + await on_device_updated(device) + + mock_wg.add_peer.assert_not_awaited() + mock_wg.remove_peer.assert_not_awaited() + + +# --- Events (WG error handling) --- + + +@patch("wiregui.services.events.get_settings") +@patch("wiregui.services.events.firewall") +@patch("wiregui.services.events.wireguard") +async def test_on_device_created_handles_wg_error(mock_wg, mock_fw, mock_settings): + mock_settings.return_value.wg_enabled = True + mock_wg.add_peer = AsyncMock(side_effect=RuntimeError("wg failed")) + mock_fw.add_device_jump_rule = AsyncMock() + + device = _make_device() + # Should not raise — error is logged + await on_device_created(device) + + +# --- Rule events --- + + +@patch("wiregui.services.events.get_settings") +@patch("wiregui.services.events.firewall") +async def test_on_rule_created_calls_apply_rule(mock_fw, mock_settings): + mock_settings.return_value.wg_enabled = True + mock_fw.apply_rule = AsyncMock() + + rule = Rule( + action="accept", + destination="10.0.0.0/8", + port_type="tcp", + port_range="80", + user_id="00000000-0000-0000-0000-000000000000", + ) + await on_rule_created(rule) + + mock_fw.apply_rule.assert_awaited_once_with( + "00000000-0000-0000-0000-000000000000", "10.0.0.0/8", "accept", "tcp", "80", + ) diff --git a/tests/test_services_extended.py b/tests/test_services_extended.py new file mode 100644 index 0000000..c4056a0 --- /dev/null +++ b/tests/test_services_extended.py @@ -0,0 +1,203 @@ +"""Extended service tests — wireguard subprocess mocking, firewall nft mocking, email.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from wiregui.services.wireguard import PeerInfo, add_peer, get_peers, remove_peer + + +# ========== WireGuard service (mocked subprocess) ========== + + +@patch("wiregui.services.wireguard._run", new_callable=AsyncMock) +async def test_add_peer_without_psk(mock_run): + mock_run.return_value = "" + await add_peer("pubkey123", ["10.0.0.1/32", "fd00::1/128"], iface="wg-test") + mock_run.assert_awaited_once() + args = mock_run.call_args[0][0] + assert "wg" in args + assert "set" in args + assert "pubkey123" in args + assert "10.0.0.1/32,fd00::1/128" in args + + +@patch("asyncio.create_subprocess_exec") +async def test_add_peer_with_psk(mock_exec): + """PSK path uses subprocess directly with stdin.""" + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"", b"") + mock_proc.returncode = 0 + mock_exec.return_value = mock_proc + + await add_peer("pubkey456", ["10.0.0.2/32"], preshared_key="psk-data", iface="wg-test") + mock_exec.assert_awaited_once() + call_args = mock_exec.call_args[0] + assert "preshared-key" in call_args + + +@patch("wiregui.services.wireguard._run", new_callable=AsyncMock) +async def test_remove_peer(mock_run): + mock_run.return_value = "" + await remove_peer("pubkey789", iface="wg-test") + mock_run.assert_awaited_once() + args = mock_run.call_args[0][0] + assert "remove" in args + assert "pubkey789" in args + + +@patch("wiregui.services.wireguard._run", new_callable=AsyncMock) +async def test_get_peers_parses_dump(mock_run): + dump_output = ( + "privkey\tpubkey\t51820\toff\n" + "peerkey1\t(none)\t1.2.3.4:51820\t10.0.0.1/32\t1700000000\t12345\t67890\t25\n" + "peerkey2\t(none)\t(none)\t10.0.0.2/32,fd00::2/128\t0\t0\t0\t0\n" + ) + mock_run.return_value = dump_output + + peers = await get_peers(iface="wg-test") + assert len(peers) == 2 + + assert peers[0].public_key == "peerkey1" + assert peers[0].endpoint == "1.2.3.4:51820" + assert peers[0].rx_bytes == 12345 + assert peers[0].tx_bytes == 67890 + assert peers[0].latest_handshake is not None + + assert peers[1].public_key == "peerkey2" + assert peers[1].endpoint is None + assert peers[1].rx_bytes == 0 + assert peers[1].latest_handshake is None + assert len(peers[1].allowed_ips) == 2 + + +@patch("wiregui.services.wireguard._run", new_callable=AsyncMock) +async def test_get_peers_returns_empty_on_error(mock_run): + mock_run.side_effect = RuntimeError("interface not found") + peers = await get_peers(iface="wg-test") + assert peers == [] + + +# ========== Firewall (mocked nft) ========== + + +@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock) +async def test_setup_base_tables(mock_batch): + from wiregui.services.firewall import setup_base_tables + await setup_base_tables() + mock_batch.assert_awaited_once() + cmds = mock_batch.call_args[0][0] + assert any("add table" in c for c in cmds) + assert any("forward" in c for c in cmds) + assert any("postrouting" in c for c in cmds) + + +@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock) +async def test_add_user_chain(mock_batch): + from wiregui.services.firewall import add_user_chain + await add_user_chain("a1b2c3d4-0000-0000-0000-000000000000") + mock_batch.assert_awaited_once() + cmds = mock_batch.call_args[0][0] + assert any("user_a1b2c3d40000" in c for c in cmds) + + +@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock) +async def test_remove_user_chain(mock_batch): + from wiregui.services.firewall import remove_user_chain + await remove_user_chain("a1b2c3d4-0000-0000-0000-000000000000") + mock_batch.assert_awaited_once() + cmds = mock_batch.call_args[0][0] + assert any("flush" in c for c in cmds) + assert any("delete" in c for c in cmds) + + +@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock) +async def test_add_device_jump_rule(mock_batch): + from wiregui.services.firewall import add_device_jump_rule + await add_device_jump_rule("user-id-123", "10.0.0.5", "fd00::5") + mock_batch.assert_awaited_once() + cmds = mock_batch.call_args[0][0] + assert any("10.0.0.5" in c and "jump" in c for c in cmds) + assert any("fd00::5" in c and "jump" in c for c in cmds) + + +@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock) +async def test_apply_rule(mock_batch): + from wiregui.services.firewall import apply_rule + await apply_rule("user-123", "10.0.0.0/8", "accept", "tcp", "80-443") + mock_batch.assert_awaited_once() + cmds = mock_batch.call_args[0][0] + assert any("10.0.0.0/8" in c and "accept" in c and "tcp dport 80-443" in c for c in cmds) + + +@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock) +async def test_rebuild_all_rules(mock_batch): + from wiregui.services.firewall import rebuild_all_rules + await rebuild_all_rules([ + { + "user_id": "user-1", + "devices": [{"ipv4": "10.0.0.1", "ipv6": "fd00::1"}], + "rules": [ + {"destination": "0.0.0.0/0", "action": "accept", "port_type": None, "port_range": None}, + {"destination": "192.168.0.0/16", "action": "drop", "port_type": "tcp", "port_range": "22"}, + ], + } + ]) + mock_batch.assert_awaited_once() + cmds = mock_batch.call_args[0][0] + assert any("flush chain" in c and "forward" in c for c in cmds) + assert any("0.0.0.0/0" in c and "accept" in c for c in cmds) + assert any("192.168.0.0/16" in c and "drop" in c for c in cmds) + assert any("10.0.0.1" in c and "jump" in c for c in cmds) + + +@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock) +async def test_setup_masquerade(mock_batch): + from wiregui.services.firewall import setup_masquerade + await setup_masquerade(iface="wg0") + mock_batch.assert_awaited_once() + cmds = mock_batch.call_args[0][0] + assert any("masquerade" in c for c in cmds) + + +# ========== Email service (mocked smtp) ========== + + +@patch("wiregui.services.email.aiosmtplib.send", new_callable=AsyncMock) +async def test_send_email_success(mock_send, monkeypatch): + monkeypatch.setattr("wiregui.services.email.get_settings", lambda: type("S", (), { + "smtp_host": "smtp.test.com", + "smtp_port": 587, + "smtp_user": "user", + "smtp_password": "pass", + "smtp_from": "test@test.com", + })()) + + from wiregui.services.email import send_email + result = await send_email("to@test.com", "Subject", "Body") + assert result is True + mock_send.assert_awaited_once() + + +async def test_send_email_no_smtp_configured(monkeypatch): + monkeypatch.setattr("wiregui.services.email.get_settings", lambda: type("S", (), { + "smtp_host": None, + })()) + + from wiregui.services.email import send_email + result = await send_email("to@test.com", "Subject", "Body") + assert result is False + + +@patch("wiregui.services.email.aiosmtplib.send", new_callable=AsyncMock) +async def test_send_magic_link(mock_send, monkeypatch): + monkeypatch.setattr("wiregui.services.email.get_settings", lambda: type("S", (), { + "smtp_host": "smtp.test.com", + "smtp_port": 587, + "smtp_user": "u", + "smtp_password": "p", + "smtp_from": "noreply@test.com", + })()) + + from wiregui.services.email import send_magic_link + result = await send_magic_link("user@test.com", "https://app.test/magic/123/token") + assert result is True + mock_send.assert_awaited_once() diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..113aa95 --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,231 @@ +"""Tests for background tasks — VPN session expiry and connectivity checks.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from sqlmodel import select + +from wiregui.auth.passwords import hash_password +from wiregui.models.configuration import Configuration +from wiregui.models.connectivity_check import ConnectivityCheck +from wiregui.models.device import Device +from wiregui.models.user import User +from wiregui.utils.time import utcnow + + +# --- VPN session expiry --- + + +async def test_vpn_session_expiry_removes_expired_peers(session, monkeypatch): + """Users whose session expired should have their WG peers removed.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.vpn_session.async_session", mock_session) + + # Create config with 1-hour session duration + config = Configuration(vpn_session_duration=3600) + session.add(config) + await session.flush() + + # Create a user who signed in 2 hours ago (expired) + expired_user = User( + email="expired@example.com", + password_hash=hash_password("pw"), + last_signed_in_at=utcnow() - timedelta(hours=2), + ) + session.add(expired_user) + await session.flush() + + device = Device(name="laptop", public_key="pk-expired", user_id=expired_user.id) + session.add(device) + await session.flush() + + # Create a user who signed in 30 min ago (still valid) + active_user = User( + email="active@example.com", + password_hash=hash_password("pw"), + last_signed_in_at=utcnow() - timedelta(minutes=30), + ) + session.add(active_user) + await session.flush() + + active_device = Device(name="phone", public_key="pk-active", user_id=active_user.id) + session.add(active_device) + await session.flush() + + # Mock WireGuard + with patch("wiregui.tasks.vpn_session.wireguard") as mock_wg: + mock_wg.remove_peer = AsyncMock() + + from wiregui.tasks.vpn_session import _expire_sessions + await _expire_sessions() + + # Only expired user's peer should be removed + mock_wg.remove_peer.assert_awaited_once_with(public_key="pk-expired") + + +async def test_vpn_session_no_expiry_when_duration_zero(session, monkeypatch): + """When vpn_session_duration is 0 (unlimited), no peers should be removed.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.vpn_session.async_session", mock_session) + + config = Configuration(vpn_session_duration=0) + session.add(config) + await session.flush() + + user = User( + email="unlimited@example.com", + last_signed_in_at=utcnow() - timedelta(days=365), + ) + session.add(user) + await session.flush() + + with patch("wiregui.tasks.vpn_session.wireguard") as mock_wg: + mock_wg.remove_peer = AsyncMock() + + from wiregui.tasks.vpn_session import _expire_sessions + await _expire_sessions() + + mock_wg.remove_peer.assert_not_awaited() + + +async def test_vpn_session_no_expiry_when_no_config(session, monkeypatch): + """When no Configuration exists, no peers should be removed.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.vpn_session.async_session", mock_session) + + # No Configuration row at all + + with patch("wiregui.tasks.vpn_session.wireguard") as mock_wg: + mock_wg.remove_peer = AsyncMock() + + from wiregui.tasks.vpn_session import _expire_sessions + await _expire_sessions() + + mock_wg.remove_peer.assert_not_awaited() + + +async def test_vpn_session_skips_disabled_users(session, monkeypatch): + """Disabled users should be skipped even if their session is expired.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.vpn_session.async_session", mock_session) + + config = Configuration(vpn_session_duration=3600) + session.add(config) + await session.flush() + + user = User( + email="disabled-session@example.com", + last_signed_in_at=utcnow() - timedelta(hours=2), + disabled_at=utcnow(), + ) + session.add(user) + await session.flush() + + device = Device(name="d", public_key="pk-disabled-session", user_id=user.id) + session.add(device) + await session.flush() + + with patch("wiregui.tasks.vpn_session.wireguard") as mock_wg: + mock_wg.remove_peer = AsyncMock() + + from wiregui.tasks.vpn_session import _expire_sessions + await _expire_sessions() + + mock_wg.remove_peer.assert_not_awaited() + + +# --- Connectivity checks --- + + +async def test_connectivity_check_success(session, monkeypatch): + """Successful connectivity check should store result in DB.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.connectivity.async_session", mock_session) + + # Mock httpx to return a successful response + import httpx + + class MockResponse: + status_code = 200 + headers = {"content-type": "text/plain"} + text = "203.0.113.1" + + class MockAsyncClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def get(self, url): + return MockResponse() + + monkeypatch.setattr("wiregui.tasks.connectivity.httpx.AsyncClient", lambda **kw: MockAsyncClient()) + + from wiregui.tasks.connectivity import _check_connectivity + await _check_connectivity() + + result = (await session.execute(select(ConnectivityCheck).limit(1))).scalar_one() + assert result.response_code == 200 + assert result.response_body == "203.0.113.1" + + +async def test_connectivity_check_failure(session, monkeypatch): + """Failed connectivity check should store error and create notification.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.connectivity.async_session", mock_session) + + class MockAsyncClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def get(self, url): + raise ConnectionError("Network unreachable") + + monkeypatch.setattr("wiregui.tasks.connectivity.httpx.AsyncClient", lambda **kw: MockAsyncClient()) + + from wiregui.services import notifications + notifications.clear_all() + + from wiregui.tasks.connectivity import _check_connectivity + await _check_connectivity() + + result = (await session.execute(select(ConnectivityCheck).limit(1))).scalar_one() + assert result.response_code is None + assert "Network unreachable" in result.response_body + + assert notifications.count() > 0 + assert "connectivity" in notifications.current()[0].message.lower() diff --git a/tests/test_tasks_extended.py b/tests/test_tasks_extended.py new file mode 100644 index 0000000..67b25cb --- /dev/null +++ b/tests/test_tasks_extended.py @@ -0,0 +1,229 @@ +"""Extended task tests — stats polling, reconciliation, OIDC refresh.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from sqlmodel import select + +from wiregui.auth.passwords import hash_password +from wiregui.models.configuration import Configuration +from wiregui.models.device import Device +from wiregui.models.oidc_connection import OIDCConnection +from wiregui.models.user import User +from wiregui.services.wireguard import PeerInfo +from wiregui.utils.time import utcnow + + +# ========== Stats task ========== + + +async def test_stats_update_from_wg_peers(session, monkeypatch): + """Stats task should update device records from WireGuard peer data.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.stats.async_session", mock_session) + + user = User(email="stats-user@test.com") + session.add(user) + await session.flush() + + device = Device(name="stats-dev", public_key="pk-stats-test", user_id=user.id) + session.add(device) + await session.flush() + + mock_peers = [ + PeerInfo( + public_key="pk-stats-test", + endpoint="1.2.3.4:51820", + rx_bytes=123456, + tx_bytes=789012, + latest_handshake=utcnow(), + ) + ] + + with patch("wiregui.tasks.stats.wireguard") as mock_wg: + mock_wg.get_peers = AsyncMock(return_value=mock_peers) + from wiregui.tasks.stats import _update_stats + await _update_stats() + + refreshed = await session.get(Device, device.id) + assert refreshed.rx_bytes == 123456 + assert refreshed.tx_bytes == 789012 + assert refreshed.remote_ip == "1.2.3.4" + assert refreshed.latest_handshake is not None + + +async def test_stats_no_peers_is_noop(session, monkeypatch): + """No WG peers should result in no DB changes.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.stats.async_session", mock_session) + + with patch("wiregui.tasks.stats.wireguard") as mock_wg: + mock_wg.get_peers = AsyncMock(return_value=[]) + from wiregui.tasks.stats import _update_stats + await _update_stats() # Should not raise + + +async def test_stats_unmatched_peer_ignored(session, monkeypatch): + """Peers not matching any device should be ignored.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.stats.async_session", mock_session) + + mock_peers = [ + PeerInfo(public_key="unknown-peer-key", rx_bytes=100, tx_bytes=200) + ] + + with patch("wiregui.tasks.stats.wireguard") as mock_wg: + mock_wg.get_peers = AsyncMock(return_value=mock_peers) + from wiregui.tasks.stats import _update_stats + await _update_stats() # Should not raise + + +# ========== Reconciliation task ========== + + +async def test_reconcile_adds_missing_peers(session, monkeypatch): + """Devices in DB but not in WG should be added.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.reconcile.async_session", mock_session) + + user = User(email="reconcile@test.com") + session.add(user) + await session.flush() + + device = Device(name="missing", public_key="pk-missing", ipv4="10.0.0.5", user_id=user.id) + session.add(device) + await session.flush() + + with patch("wiregui.tasks.reconcile.wireguard") as mock_wg: + mock_wg.get_peers = AsyncMock(return_value=[]) # WG has no peers + mock_wg.add_peer = AsyncMock() + mock_wg.remove_peer = AsyncMock() + + from wiregui.tasks.reconcile import reconcile + await reconcile() + + mock_wg.add_peer.assert_awaited_once() + call_kwargs = mock_wg.add_peer.call_args[1] + assert call_kwargs["public_key"] == "pk-missing" + assert "10.0.0.5/32" in call_kwargs["allowed_ips"] + mock_wg.remove_peer.assert_not_awaited() + + +async def test_reconcile_removes_orphaned_peers(session, monkeypatch): + """Peers in WG but not in DB should be removed.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.reconcile.async_session", mock_session) + + # No devices in DB, but WG has a peer + orphan = PeerInfo(public_key="pk-orphan", rx_bytes=0, tx_bytes=0) + + with patch("wiregui.tasks.reconcile.wireguard") as mock_wg: + mock_wg.get_peers = AsyncMock(return_value=[orphan]) + mock_wg.add_peer = AsyncMock() + mock_wg.remove_peer = AsyncMock() + + from wiregui.tasks.reconcile import reconcile + await reconcile() + + mock_wg.remove_peer.assert_awaited_once_with(public_key="pk-orphan") + mock_wg.add_peer.assert_not_awaited() + + +async def test_reconcile_in_sync(session, monkeypatch): + """When DB and WG match, nothing should happen.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.reconcile.async_session", mock_session) + + user = User(email="in-sync@test.com") + session.add(user) + await session.flush() + + device = Device(name="synced", public_key="pk-synced", user_id=user.id) + session.add(device) + await session.flush() + + peer = PeerInfo(public_key="pk-synced", rx_bytes=0, tx_bytes=0) + + with patch("wiregui.tasks.reconcile.wireguard") as mock_wg: + mock_wg.get_peers = AsyncMock(return_value=[peer]) + mock_wg.add_peer = AsyncMock() + mock_wg.remove_peer = AsyncMock() + + from wiregui.tasks.reconcile import reconcile + await reconcile() + + mock_wg.add_peer.assert_not_awaited() + mock_wg.remove_peer.assert_not_awaited() + + +# ========== OIDC refresh task ========== + + +async def test_oidc_refresh_no_connections_is_noop(session, monkeypatch): + """No OIDC connections should result in no refresh attempts.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.oidc_refresh.async_session", mock_session) + monkeypatch.setattr("wiregui.auth.oidc.load_providers", AsyncMock(return_value=[])) + + from wiregui.tasks.oidc_refresh import _refresh_all + await _refresh_all() # Should not raise + + +async def test_oidc_refresh_skips_unknown_provider(session, monkeypatch): + """Connections for unknown providers should be skipped.""" + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_session(): + yield session + + monkeypatch.setattr("wiregui.tasks.oidc_refresh.async_session", mock_session) + monkeypatch.setattr("wiregui.auth.oidc.load_providers", AsyncMock(return_value=[ + {"id": "known-provider", "client_id": "cid", "client_secret": "cs", "discovery_document_uri": "https://x"} + ])) + + user = User(email="oidc-skip@test.com") + session.add(user) + await session.flush() + + conn = OIDCConnection(provider="unknown-provider", refresh_token="tok", user_id=user.id) + session.add(conn) + await session.flush() + + from wiregui.tasks.oidc_refresh import _refresh_all + await _refresh_all() # Should skip gracefully diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..1b6a55d --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,120 @@ +"""Tests for utility modules.""" + +import subprocess + +import pytest +from sqlmodel import select + +from wiregui.models.device import Device +from wiregui.models.user import User +from wiregui.utils.network import allocate_ipv4, allocate_ipv6 +from wiregui.utils.wg_conf import build_client_config + + +# --- IP allocation --- + + +async def test_allocate_ipv4_first_device(session): + user = User(email="net-test@example.com") + session.add(user) + await session.flush() + + ip = await allocate_ipv4(session, "10.3.2.0/24") + assert ip.startswith("10.3.2.") + # Should not be the network (.0) or gateway (.1) + last_octet = int(ip.split(".")[-1]) + assert last_octet >= 2 + + +async def test_allocate_ipv4_skips_used(session): + user = User(email="net-skip@example.com") + session.add(user) + await session.flush() + + # Exhaust a tiny /30 network (4 addresses: .0 network, .1 gateway, .2 usable, .3 broadcast) + d1 = Device(name="d1", public_key="pk-net-1", ipv4="10.99.0.2", user_id=user.id) + session.add(d1) + await session.flush() + + # Only .2 was usable in a /30 — allocation should fail + with pytest.raises(ValueError, match="No available"): + await allocate_ipv4(session, "10.99.0.0/30") + + +async def test_allocate_ipv6(session): + user = User(email="net6-test@example.com") + session.add(user) + await session.flush() + + ip = await allocate_ipv6(session, "fd00::3:2:0/120") + assert ip.startswith("fd00::3:2:") + + +# --- WireGuard config builder --- + + +def test_build_client_config(): + device = Device( + name="test-device", + public_key="device-pub-key", + preshared_key="device-psk", + ipv4="10.3.2.5", + ipv6="fd00::3:2:5", + use_default_allowed_ips=True, + use_default_dns=True, + use_default_endpoint=True, + use_default_mtu=True, + use_default_persistent_keepalive=True, + user_id="00000000-0000-0000-0000-000000000000", + ) + + config = build_client_config(device, "PRIVATE_KEY_HERE", "SERVER_PUB_KEY") + + assert "[Interface]" in config + assert "PrivateKey = PRIVATE_KEY_HERE" in config + assert "10.3.2.5/32" in config + assert "fd00::3:2:5/128" in config + assert "[Peer]" in config + assert "PublicKey = SERVER_PUB_KEY" in config + assert "PresharedKey = device-psk" in config + assert "Endpoint = " in config + + +def test_build_client_config_no_psk(): + device = Device( + name="no-psk", + public_key="pub", + preshared_key=None, + ipv4="10.3.2.6", + ipv6=None, + use_default_allowed_ips=True, + use_default_dns=True, + use_default_endpoint=True, + use_default_mtu=True, + use_default_persistent_keepalive=True, + user_id="00000000-0000-0000-0000-000000000000", + ) + + config = build_client_config(device, "PRIV", "SERVPUB") + assert "PresharedKey" not in config + assert "fd00::" not in config # no ipv6 + + +# --- Crypto (only if wg is installed) --- + + +def test_generate_keypair(): + """Test keypair generation — requires `wg` CLI to be installed.""" + try: + subprocess.run(["wg", "--version"], capture_output=True, check=True) + except FileNotFoundError: + pytest.skip("wg CLI not installed") + + from wiregui.utils.crypto import generate_keypair, generate_preshared_key + + priv, pub = generate_keypair() + assert len(priv) == 44 # base64-encoded 32 bytes + assert len(pub) == 44 + + psk = generate_preshared_key() + assert len(psk) == 44 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..993c195 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2016 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" }, + { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" }, + { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" }, + { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" }, + { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" }, + { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" }, + { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" }, + { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, + { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, + { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, + { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, + { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, + { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, + { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, + { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosmtplib" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ad/240a7ce4e50713b111dff8b781a898d8d4770e5d6ad4899103f84c86005c/aiosmtplib-5.1.0.tar.gz", hash = "sha256:2504a23b2b63c9de6bc4ea719559a38996dba68f73f6af4eb97be20ee4c5e6c4", size = 66176, upload-time = "2026-01-25T01:51:11.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/82/70f2c452acd7ed18c558c8ace9a8cf4fdcc70eae9a41749b5bdc53eb6f45/aiosmtplib-5.1.0-py3-none-any.whl", hash = "sha256:368029440645b486b69db7029208a7a78c6691b90d24a5332ddba35d9109d55b", size = 27778, upload-time = "2026-01-25T01:51:10.026Z" }, +] + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "authlib" +version = "1.6.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + +[[package]] +name = "cbor2" +version = "5.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/cb/09939728be094d155b5d4ac262e39877875f5f7e36eea66beb359f647bd0/cbor2-5.9.0.tar.gz", hash = "sha256:85c7a46279ac8f226e1059275221e6b3d0e370d2bb6bd0500f9780781615bcea", size = 111231, upload-time = "2026-03-22T15:56:50.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c5/4901e21a8afe9448fd947b11e8f383903207cd6dd0800e5f5a386838de5b/cbor2-5.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fbb06f34aa645b4deca66643bba3d400d20c15312d1fe88d429be60c1ab50f27", size = 71284, upload-time = "2026-03-22T15:56:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/1b/10/df643a381aebc3f05486de4813662bc58accb640fc3275cb276a75e89694/cbor2-5.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac684fe195c39821fca70d18afbf748f728aefbfbf88456018d299e559b8cae0", size = 287682, upload-time = "2026-03-22T15:56:24.024Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/8aa6b766059ae4a0ca1ec3ff96fe3823a69a7be880dba2e249f7fbe2700b/cbor2-5.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a54fbb32cb828c214f7f333a707e4aec61182e7efdc06ea5d9596d3ecee624a", size = 288009, upload-time = "2026-03-22T15:56:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/6236bc25c183a9cf7e8062e5dddf9eae9b0b14ebf14a58a69fe5a1e872c6/cbor2-5.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4753a6d1bc71054d9179557bc65740860f185095ccb401d46637fff028a5b3ec", size = 280437, upload-time = "2026-03-22T15:56:26.479Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0a/84328d23c3c68874ac6497edb9b1900579a1028efa54734df3f1762bbc15/cbor2-5.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:380e534482b843e43442b87d8777a7bf9bed20cb7526f89b780c3400f617304b", size = 282247, upload-time = "2026-03-22T15:56:28.644Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f6/89b4627e09d028c8e5fcaf7cb55f225c33ce6e037ec1844e65d02bcfa945/cbor2-5.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:dcf0f695873e5c94bd072d6af8698e72b8fb7f7a18f37e0bced1041b7111a6cf", size = 70089, upload-time = "2026-03-22T15:56:29.801Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7c/efadcd5f0102db692490e4e206988a2f98d39a09912090db497a2b800885/cbor2-5.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:f7c9751a9611601ab326d8f5837f01379195bbf06175fb4effeb552140e7c9e8", size = 65466, upload-time = "2026-03-22T15:56:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/08/7d/9ccc36d10ef96e6038e48046ebe1ce35a1e7814da0e1e204d09e6ef09b8d/cbor2-5.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23606d31ba1368bd1b6602e3020ee88fe9523ca80e8630faf6b2fc904fd84560", size = 71500, upload-time = "2026-03-22T15:56:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/70/e1/a6cca2cc72e13f00030c6a649f57ae703eb2c620806ab70c40db8eab33fa/cbor2-5.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0322296b9d52f55880e300ba8ba09ecf644303b99b51138bbb1c0fb644fa7c3e", size = 286953, upload-time = "2026-03-22T15:56:33.292Z" }, + { url = "https://files.pythonhosted.org/packages/08/3c/24cd5ef488a957d90e016f200a3aad820e4c2f85edd61c9fe4523007a1ee/cbor2-5.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:422817286c1d0ce947fb2f7eca9212b39bddd7231e8b452e2d2cc52f15332dba", size = 285454, upload-time = "2026-03-22T15:56:34.703Z" }, + { url = "https://files.pythonhosted.org/packages/a4/35/dca96818494c0ba47cdd73e8d809b27fa91f8fa0ce32a068a09237687454/cbor2-5.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9a4907e0c3035bb8836116854ed8e56d8aef23909d601fa59706320897ec2551", size = 279441, upload-time = "2026-03-22T15:56:35.888Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/d3362378b16e53cf7e535a3f5aed8476e2109068154e24e31981ef5bde9e/cbor2-5.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fb7afe77f8d269e42d7c4b515c6fd14f1ccc0625379fb6829b269f493d16eddd", size = 279673, upload-time = "2026-03-22T15:56:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/43/d1/3533a697e5842fff7c2f64912eb251f8dcab3a8b5d88e228d6eebc3b5021/cbor2-5.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:86baf870d4c0bfc6f79de3801f3860a84ab76d9c8b0abb7f081f2c14c38d79d3", size = 71940, upload-time = "2026-03-22T15:56:38.366Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e2/c6ba75f3fb25dfa15ab6999cc8709c821987e9ed8e375d7f58539261bcb9/cbor2-5.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:7221483fad0c63afa4244624d552abf89d7dfdbc5f5edfc56fc1ff2b4b818975", size = 67639, upload-time = "2026-03-22T15:56:39.39Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/b83492b096fbef26e9cb62c1a4bf2d3cef579ea7b33138c6c37c4ae66f67/cbor2-5.9.0-py3-none-any.whl", hash = "sha256:27695cbd70c90b8de5c4a284642c2836449b14e2c2e07e3ffe0744cb7669a01b", size = 24627, upload-time = "2026-03-22T15:56:48.847Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[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/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 = "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 = "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.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "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 = "ifaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, +] + +[[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 = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[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/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" }, +] + +[[package]] +name = "lxml-html-clean" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/a4/5c62acfacd69ff4f5db395100f5cfb9b54e7ac8c69a235e4e939fd13f021/lxml_html_clean-0.4.4.tar.gz", hash = "sha256:58f39a9d632711202ed1d6d0b9b47a904e306c85de5761543b90e3e3f736acfb", size = 23899, upload-time = "2026-02-27T09:35:52.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/76/7ffc1d3005cf7749123bc47cb3ea343cd97b0ac2211bab40f57283577d0e/lxml_html_clean-0.4.4-py3-none-any.whl", hash = "sha256:ce2ef506614ecb85ee1c5fe0a2aa45b06a19514ec7949e9c8f34f06925cfabcb", size = 14565, upload-time = "2026-02-27T09:35:51.86Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markdown2" +version = "2.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/07d4a5fcaa5509221287d289323d75ac8eda5a5a4ac9de2accf7bbcc2b88/markdown2-2.5.5.tar.gz", hash = "sha256:001547e68f6e7fcf0f1cb83f7e82f48aa7d48b2c6a321f0cd20a853a8a2d1664", size = 157249, upload-time = "2026-03-02T20:46:53.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/af/4b3891eb0a49d6cfd5cbf3e9bf514c943afc2b0f13e2c57cc57cd88ecc21/markdown2-2.5.5-py3-none-any.whl", hash = "sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941", size = 56250, upload-time = "2026-03-02T20:46:52.032Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "nicegui" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "certifi" }, + { name = "docutils" }, + { name = "fastapi" }, + { name = "h11" }, + { name = "httpx" }, + { name = "ifaddr" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "lxml-html-clean" }, + { name = "markdown2" }, + { name = "orjson", marker = "platform_machine != 'i386' and platform_machine != 'i686' and platform_python_implementation != 'PyPy'" }, + { name = "pydantic-core" }, + { name = "pygments" }, + { name = "python-engineio" }, + { name = "python-multipart" }, + { name = "python-socketio", extra = ["asyncio-client"] }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "watchfiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/ed046018db555c34ebc17738284d2f85bf9a544734cd44a87311128619a5/nicegui-3.9.0.tar.gz", hash = "sha256:7ae9046b321d029c438f7cd54a697838ed1962cecb92c622912283c66c8bf8f6", size = 19031869, upload-time = "2026-03-19T09:51:52.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/11/f7f911f284ceb1b038c26d6f4833bc86d6583d5280156274fdb79be7dcfe/nicegui-3.9.0-py3-none-any.whl", hash = "sha256:4adfdb87a55e30b7fef05ab782efc030534ae6ad9afa330db856dfbb258e23c9", size = 19613351, upload-time = "2026-03-19T09:51:48.769Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, + { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, + { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, +] + +[[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.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +] + +[[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 = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[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 = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyopenssl" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, +] + +[[package]] +name = "pyotp" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/b2/1d5994ba2acde054a443bd5e2d384175449c7d2b6d1a0614dbca3a63abfc/pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63", size = 17763, upload-time = "2023-07-27T23:41:03.295Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612", size = 13376, upload-time = "2023-07-27T23:41:01.685Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/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-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-engineio" +version = "4.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" }, +] + +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "python-socketio" +version = "5.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" }, +] + +[package.optional-dependencies] +asyncio-client = [ + { name = "aiohttp" }, +] + +[[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 = "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/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 = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + +[package.optional-dependencies] +pil = [ + { name = "pillow" }, +] + +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, +] + +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + +[[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 = "sqlalchemy" +version = "2.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, + { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.37" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/26/1d2faa0fd5a765267f49751de533adac6b9ff9366c7c6e7692df4f32230f/sqlmodel-0.0.37.tar.gz", hash = "sha256:d2c19327175794faf50b1ee31cc966764f55b1dedefc046450bc5741a3d68352", size = 85527, upload-time = "2026-02-21T16:39:47.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/e1/7c8d18e737433f3b5bbe27b56a9072a9fcb36342b48f1bef34b6da1d61f2/sqlmodel-0.0.37-py3-none-any.whl", hash = "sha256:2137a4045ef3fd66a917a7717ada959a1ceb3630d95e1f6aaab39dd2c0aef278", size = 27224, upload-time = "2026-02-21T16:39:47.781Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +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 = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "webauthn" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cbor2" }, + { name = "cryptography" }, + { name = "pyasn1" }, + { name = "pyopenssl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/f4/9529bcf85ef46c76842b84c66ffa8ec31f18e3aacd1330b62f440077b45b/webauthn-2.7.1.tar.gz", hash = "sha256:2a1ebbfffc4a83e31d3db5d69113944bc49d05fae77770c2d4e388386cb9656e", size = 124256, upload-time = "2026-02-11T23:36:02.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/5b/f73513367a9d34b199de916b44306acfa4027b57f7e22200421212b1f763/webauthn-2.7.1-py3-none-any.whl", hash = "sha256:d57e9613c65e0c6a4db7ee715fb49ebdf3c4a6eb3979729eeb497c99105e8181", size = 71684, upload-time = "2026-02-11T23:36:00.864Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + +[[package]] +name = "wiregui" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aiosmtplib" }, + { name = "alembic" }, + { name = "asyncpg" }, + { name = "authlib" }, + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "httpx" }, + { name = "loguru" }, + { name = "nicegui" }, + { name = "pydantic-settings" }, + { name = "pyotp" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "python3-saml" }, + { name = "qrcode", extra = ["pil"] }, + { name = "redis" }, + { name = "sqlmodel" }, + { name = "webauthn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "respx" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosmtplib", specifier = ">=3.0" }, + { name = "alembic", specifier = ">=1.14" }, + { name = "asyncpg", specifier = ">=0.30" }, + { name = "authlib", specifier = ">=1.4" }, + { name = "bcrypt", specifier = ">=4.0" }, + { name = "cryptography", specifier = ">=44" }, + { name = "httpx", specifier = ">=0.28" }, + { name = "loguru", specifier = ">=0.7.3" }, + { name = "nicegui", specifier = ">=2.12" }, + { name = "pydantic-settings", specifier = ">=2.7" }, + { name = "pyotp", specifier = ">=2.9" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3" }, + { name = "python3-saml", specifier = ">=1.16" }, + { name = "qrcode", extras = ["pil"], specifier = ">=8.0" }, + { name = "redis", specifier = ">=5.2" }, + { name = "sqlmodel", specifier = ">=0.0.22" }, + { name = "webauthn", specifier = ">=2.2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "respx", specifier = ">=0.22.0" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[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/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" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] diff --git a/wiregui/__init__.py b/wiregui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wiregui/api/__init__.py b/wiregui/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wiregui/api/deps.py b/wiregui/api/deps.py new file mode 100644 index 0000000..48af738 --- /dev/null +++ b/wiregui/api/deps.py @@ -0,0 +1,38 @@ +"""Shared FastAPI dependencies for the REST API.""" + +from collections.abc import AsyncGenerator + +from fastapi import Depends, HTTPException, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from wiregui.auth.api_token import resolve_bearer_token +from wiregui.db import async_session +from wiregui.models.user import User + + +async def get_db() -> AsyncGenerator[AsyncSession]: + async with async_session() as session: + yield session + + +async def get_current_api_user( + request: Request, + session: AsyncSession = Depends(get_db), +) -> User: + """Extract Bearer token from Authorization header and resolve the user.""" + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Missing or invalid Authorization header") + + token = auth[7:] + user = await resolve_bearer_token(session, token) + if user is None: + raise HTTPException(status_code=401, detail="Invalid or expired API token") + return user + + +async def require_admin(user: User = Depends(get_current_api_user)) -> User: + """Require the authenticated user to be an admin.""" + if user.role != "admin": + raise HTTPException(status_code=403, detail="Admin access required") + return user diff --git a/wiregui/api/v0/__init__.py b/wiregui/api/v0/__init__.py new file mode 100644 index 0000000..30c7ea9 --- /dev/null +++ b/wiregui/api/v0/__init__.py @@ -0,0 +1,11 @@ +"""v0 API router — aggregates all sub-routers.""" + +from fastapi import APIRouter + +from wiregui.api.v0 import configuration, devices, rules, users + +router = APIRouter(prefix="/v0") +router.include_router(users.router) +router.include_router(devices.router) +router.include_router(rules.router) +router.include_router(configuration.router) diff --git a/wiregui/api/v0/configuration.py b/wiregui/api/v0/configuration.py new file mode 100644 index 0000000..1b59390 --- /dev/null +++ b/wiregui/api/v0/configuration.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from wiregui.api.deps import get_db, require_admin +from wiregui.models.configuration import Configuration +from wiregui.models.user import User +from wiregui.schemas.configuration import ConfigurationRead, ConfigurationUpdate + +router = APIRouter(prefix="/configuration", tags=["configuration"]) + + +async def _get_config(session: AsyncSession) -> Configuration: + result = await session.execute(select(Configuration).limit(1)) + config = result.scalar_one_or_none() + if not config: + config = Configuration() + session.add(config) + await session.commit() + await session.refresh(config) + return config + + +@router.get("/", response_model=ConfigurationRead) +async def get_configuration( + session: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +): + return await _get_config(session) + + +@router.put("/", response_model=ConfigurationRead) +async def update_configuration( + body: ConfigurationUpdate, + session: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +): + config = await _get_config(session) + + for key, val in body.model_dump(exclude_unset=True).items(): + setattr(config, key, val) + + session.add(config) + await session.commit() + await session.refresh(config) + return config diff --git a/wiregui/api/v0/devices.py b/wiregui/api/v0/devices.py new file mode 100644 index 0000000..0a6c751 --- /dev/null +++ b/wiregui/api/v0/devices.py @@ -0,0 +1,119 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from loguru import logger +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from wiregui.api.deps import get_current_api_user, get_db +from wiregui.config import get_settings +from wiregui.models.device import Device +from wiregui.models.user import User +from wiregui.schemas.device import DeviceCreate, DeviceRead, DeviceUpdate +from wiregui.services.events import on_device_created, on_device_deleted, on_device_updated +from wiregui.utils.crypto import generate_keypair, generate_preshared_key +from wiregui.utils.network import allocate_ipv4, allocate_ipv6 + +router = APIRouter(prefix="/devices", tags=["devices"]) + + +@router.get("/", response_model=list[DeviceRead]) +async def list_devices( + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_api_user), +): + if current_user.role == "admin": + result = await session.execute(select(Device).order_by(Device.inserted_at.desc())) + else: + result = await session.execute( + select(Device).where(Device.user_id == current_user.id).order_by(Device.inserted_at.desc()) + ) + return result.scalars().all() + + +@router.get("/{device_id}", response_model=DeviceRead) +async def get_device( + device_id: UUID, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_api_user), +): + device = await session.get(Device, device_id) + if not device: + raise HTTPException(404, "Device not found") + if current_user.role != "admin" and device.user_id != current_user.id: + raise HTTPException(403, "Access denied") + return device + + +@router.post("/", response_model=DeviceRead, status_code=201) +async def create_device( + body: DeviceCreate, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_api_user), +): + settings = get_settings() + owner_id = body.user_id if (body.user_id and current_user.role == "admin") else current_user.id + + _private_key, public_key = generate_keypair() + psk = generate_preshared_key() + ipv4 = await allocate_ipv4(session, settings.wg_ipv4_network) + ipv6 = await allocate_ipv6(session, settings.wg_ipv6_network) + + device = Device( + name=body.name, + description=body.description, + public_key=public_key, + preshared_key=psk, + ipv4=ipv4, + ipv6=ipv6, + user_id=owner_id, + ) + session.add(device) + await session.commit() + await session.refresh(device) + + logger.info("API: device created {} ({})", device.name, device.ipv4) + await on_device_created(device) + return device + + +@router.put("/{device_id}", response_model=DeviceRead) +async def update_device( + device_id: UUID, + body: DeviceUpdate, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_api_user), +): + device = await session.get(Device, device_id) + if not device: + raise HTTPException(404, "Device not found") + if current_user.role != "admin" and device.user_id != current_user.id: + raise HTTPException(403, "Access denied") + + for key, val in body.model_dump(exclude_unset=True).items(): + setattr(device, key, val) + + session.add(device) + await session.commit() + await session.refresh(device) + + await on_device_updated(device) + return device + + +@router.delete("/{device_id}", status_code=204) +async def delete_device( + device_id: UUID, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_api_user), +): + device = await session.get(Device, device_id) + if not device: + raise HTTPException(404, "Device not found") + if current_user.role != "admin" and device.user_id != current_user.id: + raise HTTPException(403, "Access denied") + + await session.delete(device) + await session.commit() + logger.info("API: device deleted {}", device.name) + await on_device_deleted(device) diff --git a/wiregui/api/v0/rules.py b/wiregui/api/v0/rules.py new file mode 100644 index 0000000..143716f --- /dev/null +++ b/wiregui/api/v0/rules.py @@ -0,0 +1,86 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from loguru import logger +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from wiregui.api.deps import get_db, require_admin +from wiregui.models.rule import Rule +from wiregui.models.user import User +from wiregui.schemas.rule import RuleCreate, RuleRead, RuleUpdate +from wiregui.services.events import on_rule_created, on_rule_deleted + +router = APIRouter(prefix="/rules", tags=["rules"]) + + +@router.get("/", response_model=list[RuleRead]) +async def list_rules( + session: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +): + result = await session.execute(select(Rule).order_by(Rule.inserted_at.desc())) + return result.scalars().all() + + +@router.get("/{rule_id}", response_model=RuleRead) +async def get_rule( + rule_id: UUID, + session: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +): + rule = await session.get(Rule, rule_id) + if not rule: + raise HTTPException(404, "Rule not found") + return rule + + +@router.post("/", response_model=RuleRead, status_code=201) +async def create_rule( + body: RuleCreate, + session: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +): + rule = Rule(**body.model_dump()) + session.add(rule) + await session.commit() + await session.refresh(rule) + + logger.info("API: rule created {} -> {}", rule.action, rule.destination) + await on_rule_created(rule) + return rule + + +@router.put("/{rule_id}", response_model=RuleRead) +async def update_rule( + rule_id: UUID, + body: RuleUpdate, + session: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +): + rule = await session.get(Rule, rule_id) + if not rule: + raise HTTPException(404, "Rule not found") + + for key, val in body.model_dump(exclude_unset=True).items(): + setattr(rule, key, val) + + session.add(rule) + await session.commit() + await session.refresh(rule) + return rule + + +@router.delete("/{rule_id}", status_code=204) +async def delete_rule( + rule_id: UUID, + session: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +): + rule = await session.get(Rule, rule_id) + if not rule: + raise HTTPException(404, "Rule not found") + await session.delete(rule) + await session.commit() + logger.info("API: rule deleted {} {}", rule.action, rule.destination) + await on_rule_deleted(rule) diff --git a/wiregui/api/v0/users.py b/wiregui/api/v0/users.py new file mode 100644 index 0000000..037e8db --- /dev/null +++ b/wiregui/api/v0/users.py @@ -0,0 +1,86 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from wiregui.api.deps import get_db, require_admin +from wiregui.auth.passwords import hash_password +from wiregui.models.user import User +from wiregui.schemas.user import UserCreate, UserRead, UserUpdate + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/", response_model=list[UserRead]) +async def list_users( + session: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +): + result = await session.execute(select(User).order_by(User.email)) + return result.scalars().all() + + +@router.get("/{user_id}", response_model=UserRead) +async def get_user( + user_id: UUID, + session: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +): + user = await session.get(User, user_id) + if not user: + raise HTTPException(404, "User not found") + return user + + +@router.post("/", response_model=UserRead, status_code=201) +async def create_user( + body: UserCreate, + session: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +): + user = User( + email=body.email, + password_hash=hash_password(body.password), + role=body.role, + ) + session.add(user) + await session.commit() + await session.refresh(user) + return user + + +@router.put("/{user_id}", response_model=UserRead) +async def update_user( + user_id: UUID, + body: UserUpdate, + session: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +): + user = await session.get(User, user_id) + if not user: + raise HTTPException(404, "User not found") + + updates = body.model_dump(exclude_unset=True) + if "password" in updates: + updates["password_hash"] = hash_password(updates.pop("password")) + for key, val in updates.items(): + setattr(user, key, val) + + session.add(user) + await session.commit() + await session.refresh(user) + return user + + +@router.delete("/{user_id}", status_code=204) +async def delete_user( + user_id: UUID, + session: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +): + user = await session.get(User, user_id) + if not user: + raise HTTPException(404, "User not found") + await session.delete(user) + await session.commit() diff --git a/wiregui/auth/__init__.py b/wiregui/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wiregui/auth/api_token.py b/wiregui/auth/api_token.py new file mode 100644 index 0000000..125d256 --- /dev/null +++ b/wiregui/auth/api_token.py @@ -0,0 +1,42 @@ +"""API token authentication — Bearer token via Authorization header.""" + +import hashlib +import secrets + +from loguru import logger +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from wiregui.models.api_token import ApiToken +from wiregui.models.user import User +from wiregui.utils.time import utcnow + + +def generate_api_token() -> tuple[str, str]: + """Generate a new API token. Returns (plaintext_token, token_hash).""" + plaintext = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(plaintext.encode()).hexdigest() + return plaintext, token_hash + + +async def resolve_bearer_token(session: AsyncSession, token: str) -> User | None: + """Look up a Bearer token and return the associated user, or None.""" + token_hash = hashlib.sha256(token.encode()).hexdigest() + result = await session.execute( + select(ApiToken).where(ApiToken.token_hash == token_hash) + ) + api_token = result.scalar_one_or_none() + if api_token is None: + return None + + # Check expiry + if api_token.expires_at and api_token.expires_at < utcnow(): + logger.debug("API token expired for user_id={}", api_token.user_id) + return None + + # Resolve user + user = await session.get(User, api_token.user_id) + if user is None or user.disabled_at is not None: + return None + + return user diff --git a/wiregui/auth/jwt.py b/wiregui/auth/jwt.py new file mode 100644 index 0000000..c007917 --- /dev/null +++ b/wiregui/auth/jwt.py @@ -0,0 +1,26 @@ +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt + +from wiregui.config import get_settings + +ALGORITHM = "HS256" +DEFAULT_EXPIRE_HOURS = 8 + + +def create_access_token( + user_id: str, + role: str, + expires_delta: timedelta | None = None, +) -> str: + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(hours=DEFAULT_EXPIRE_HOURS)) + payload = {"sub": user_id, "role": role, "exp": expire} + return jwt.encode(payload, get_settings().secret_key, algorithm=ALGORITHM) + + +def decode_access_token(token: str) -> dict | None: + """Decode and validate a JWT. Returns the payload dict or None if invalid/expired.""" + try: + return jwt.decode(token, get_settings().secret_key, algorithms=[ALGORITHM]) + except JWTError: + return None diff --git a/wiregui/auth/mfa.py b/wiregui/auth/mfa.py new file mode 100644 index 0000000..2817602 --- /dev/null +++ b/wiregui/auth/mfa.py @@ -0,0 +1,31 @@ +"""TOTP Multi-Factor Authentication using pyotp.""" + +import io +from urllib.parse import quote + +import pyotp +import qrcode +import qrcode.image.svg + + +def generate_totp_secret() -> str: + """Generate a new random TOTP secret.""" + return pyotp.random_base32() + + +def get_totp_uri(secret: str, email: str, issuer: str = "WireGUI") -> str: + """Build an otpauth:// URI for QR code scanning.""" + return pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name=issuer) + + +def verify_totp_code(secret: str, code: str) -> bool: + """Verify a TOTP code against a secret. Allows 1 window of clock drift.""" + return pyotp.TOTP(secret).verify(code, valid_window=1) + + +def generate_totp_qr_svg(uri: str) -> str: + """Generate an SVG QR code for a TOTP provisioning URI.""" + qr = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage) + buf = io.BytesIO() + qr.save(buf) + return buf.getvalue().decode() diff --git a/wiregui/auth/middleware.py b/wiregui/auth/middleware.py new file mode 100644 index 0000000..1743054 --- /dev/null +++ b/wiregui/auth/middleware.py @@ -0,0 +1,20 @@ +"""NiceGUI auth middleware — redirects unauthenticated requests to /login.""" + +from fastapi import Request +from fastapi.responses import RedirectResponse +from starlette.middleware.base import BaseHTTPMiddleware + +# Paths that don't require authentication +PUBLIC_PREFIXES = ("/login", "/_nicegui", "/api") + + +class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + if any(request.url.path.startswith(p) for p in PUBLIC_PREFIXES): + return await call_next(request) + + # NiceGUI stores auth state in the Starlette session (cookie-backed) + if not request.session.get("authenticated"): + return RedirectResponse(url="/login") + + return await call_next(request) diff --git a/wiregui/auth/oidc.py b/wiregui/auth/oidc.py new file mode 100644 index 0000000..6e9c791 --- /dev/null +++ b/wiregui/auth/oidc.py @@ -0,0 +1,59 @@ +"""OIDC authentication via authlib — provider registry and authorization code flow.""" + +from authlib.integrations.starlette_client import OAuth +from loguru import logger + +from wiregui.db import async_session +from wiregui.models.configuration import Configuration + +# Global OAuth instance — providers are registered dynamically +oauth = OAuth() + + +async def load_providers() -> list[dict]: + """Load OIDC provider configs from the Configuration singleton.""" + from sqlmodel import select + + async with async_session() as session: + result = await session.execute(select(Configuration).limit(1)) + config = result.scalar_one_or_none() + if not config: + return [] + return config.openid_connect_providers or [] + + +async def register_providers() -> None: + """Register all configured OIDC providers with authlib. Call on startup.""" + providers = await load_providers() + for p in providers: + provider_id = p.get("id") + if not provider_id: + continue + try: + oauth.register( + name=provider_id, + client_id=p.get("client_id"), + client_secret=p.get("client_secret"), + server_metadata_url=p.get("discovery_document_uri"), + client_kwargs={"scope": p.get("scope", "openid email profile")}, + ) + logger.info("OIDC provider registered: {}", provider_id) + except Exception as e: + logger.error("Failed to register OIDC provider {}: {}", provider_id, e) + + +def get_client(provider_id: str): + """Get an authlib OAuth client for a registered provider.""" + client = oauth.create_client(provider_id) + if client is None: + raise ValueError(f"OIDC provider '{provider_id}' is not registered") + return client + + +async def get_provider_config(provider_id: str) -> dict | None: + """Get the config dict for a specific provider.""" + providers = await load_providers() + for p in providers: + if p.get("id") == provider_id: + return p + return None diff --git a/wiregui/auth/passwords.py b/wiregui/auth/passwords.py new file mode 100644 index 0000000..8866f90 --- /dev/null +++ b/wiregui/auth/passwords.py @@ -0,0 +1,9 @@ +import bcrypt + + +def hash_password(plain: str) -> str: + return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode() + + +def verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode(), hashed.encode()) diff --git a/wiregui/auth/saml.py b/wiregui/auth/saml.py new file mode 100644 index 0000000..c624a35 --- /dev/null +++ b/wiregui/auth/saml.py @@ -0,0 +1,114 @@ +"""SAML SP-initiated SSO via python3-saml.""" + +from loguru import logger +from onelogin.saml2.auth import OneLogin_Saml2_Auth +from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser + +from wiregui.config import get_settings + + +def _build_saml_settings(provider_config: dict) -> dict: + """Build python3-saml settings dict from our provider config.""" + settings = get_settings() + base_url = settings.external_url + + # Parse IdP metadata XML to extract endpoints and certs + idp_data = OneLogin_Saml2_IdPMetadataParser.parse(provider_config.get("metadata", "")) + idp_settings = idp_data.get("idp", {}) + + return { + "strict": True, + "debug": False, + "sp": { + "entityId": f"{base_url}/auth/saml/{provider_config['id']}/metadata", + "assertionConsumerService": { + "url": f"{base_url}/auth/saml/{provider_config['id']}/callback", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + }, + "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + }, + "idp": idp_settings, + "security": { + "authnRequestsSigned": provider_config.get("sign_requests", True), + "wantAssertionsSigned": provider_config.get("signed_assertion_in_resp", True), + "signMetadata": provider_config.get("sign_metadata", True), + }, + } + + +def prepare_saml_request(request_data: dict) -> dict: + """Prepare a dict that python3-saml expects from an HTTP request. + + Args: + request_data: dict with keys: http_host, script_name, server_port, + get_data (dict), post_data (dict), https (str "on"/"off") + """ + return { + "http_host": request_data.get("http_host", "localhost"), + "script_name": request_data.get("script_name", ""), + "server_port": request_data.get("server_port", 443), + "get_data": request_data.get("get_data", {}), + "post_data": request_data.get("post_data", {}), + "https": request_data.get("https", "on"), + } + + +def create_saml_auth(provider_config: dict, request_data: dict) -> OneLogin_Saml2_Auth: + """Create a python3-saml Auth instance for a provider.""" + saml_settings = _build_saml_settings(provider_config) + req = prepare_saml_request(request_data) + return OneLogin_Saml2_Auth(req, saml_settings) + + +def get_login_url(auth: OneLogin_Saml2_Auth) -> str: + """Get the SSO redirect URL.""" + return auth.login() + + +def process_response(auth: OneLogin_Saml2_Auth) -> dict | None: + """Process the SAML response and return user attributes. + + Returns dict with 'email' key, or None on failure. + """ + auth.process_response() + errors = auth.get_errors() + if errors: + logger.error("SAML response errors: {}", errors) + return None + + if not auth.is_authenticated(): + logger.warning("SAML: user not authenticated") + return None + + attrs = auth.get_attributes() + name_id = auth.get_nameid() + + # Try to extract email from various attribute names + email = ( + attrs.get("email", [None])[0] + or attrs.get("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", [None])[0] + or attrs.get("urn:oid:0.9.2342.19200300.100.1.3", [None])[0] + or name_id + ) + + if not email: + logger.error("SAML: no email found in attributes or NameID") + return None + + return { + "email": email, + "name_id": name_id, + "attributes": {k: v for k, v in attrs.items()}, + } + + +def get_metadata(provider_config: dict) -> str: + """Generate SP metadata XML.""" + settings = _build_saml_settings(provider_config) + from onelogin.saml2.settings import OneLogin_Saml2_Settings + saml_settings = OneLogin_Saml2_Settings(settings, sp_validation_only=True) + metadata = saml_settings.get_sp_metadata() + errors = saml_settings.validate_metadata(metadata) + if errors: + logger.error("SP metadata validation errors: {}", errors) + return metadata.decode() if isinstance(metadata, bytes) else metadata diff --git a/wiregui/auth/seed.py b/wiregui/auth/seed.py new file mode 100644 index 0000000..2d0e1a1 --- /dev/null +++ b/wiregui/auth/seed.py @@ -0,0 +1,61 @@ +"""Seed the initial admin user and server keypair on first startup.""" + +import secrets + +from loguru import logger +from sqlmodel import select + +from wiregui.auth.passwords import hash_password +from wiregui.config import get_settings +from wiregui.db import async_session +from wiregui.models.configuration import Configuration +from wiregui.models.user import User + + +async def seed_admin() -> None: + """Create admin user if no users exist in the database.""" + async with async_session() as session: + result = await session.execute(select(User).limit(1)) + if result.scalar_one_or_none() is not None: + return # users already exist + + settings = get_settings() + password = settings.admin_password or secrets.token_urlsafe(16) + + admin = User( + email=settings.admin_email, + password_hash=hash_password(password), + role="admin", + ) + session.add(admin) + await session.commit() + + logger.info("Admin user created: {}", settings.admin_email) + if settings.admin_password is None: + logger.warning("Generated admin password: {}", password) + + +async def ensure_server_keypair() -> None: + """Generate and store the server WireGuard keypair in Configuration if missing.""" + from wiregui.utils.crypto import generate_keypair + + async with async_session() as session: + result = await session.execute(select(Configuration).limit(1)) + config = result.scalar_one_or_none() + + if config is None: + config = Configuration() + session.add(config) + + if config.server_public_key and config.server_private_key: + return # already have keys + + try: + private_key, public_key = generate_keypair() + config.server_private_key = private_key + config.server_public_key = public_key + session.add(config) + await session.commit() + logger.info("Server WireGuard keypair generated (pubkey: {}...)", public_key[:20]) + except Exception as e: + logger.warning("Could not generate server keypair (wg CLI not available?): {}", e) diff --git a/wiregui/auth/session.py b/wiregui/auth/session.py new file mode 100644 index 0000000..dc31cf5 --- /dev/null +++ b/wiregui/auth/session.py @@ -0,0 +1,22 @@ +"""Authentication helpers for NiceGUI pages.""" + +from sqlmodel import select + +from wiregui.db import async_session +from wiregui.models.user import User + + +async def authenticate_user(email: str, password: str) -> User | None: + """Verify email/password and return the User if valid, else None.""" + from wiregui.auth.passwords import verify_password + + async with async_session() as session: + stmt = select(User).where(User.email == email) + user = (await session.execute(stmt)).scalar_one_or_none() + if user is None or user.password_hash is None: + return None + if not verify_password(password, user.password_hash): + return None + if user.disabled_at is not None: + return None + return user diff --git a/wiregui/auth/webauthn.py b/wiregui/auth/webauthn.py new file mode 100644 index 0000000..b11249b --- /dev/null +++ b/wiregui/auth/webauthn.py @@ -0,0 +1,134 @@ +"""WebAuthn (FIDO2) MFA via the webauthn library. + +Registration and authentication ceremonies for platform (native) and +cross-platform (portable/security key) authenticators. +""" + +import json +from uuid import UUID + +from loguru import logger +from webauthn import ( + generate_authentication_options, + generate_registration_options, + verify_authentication_response, + verify_registration_response, +) +from webauthn.helpers import bytes_to_base64url, base64url_to_bytes +from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement, +) + +from wiregui.config import get_settings + + +def _rp_id() -> str: + """Get the Relying Party ID from the external URL hostname.""" + from urllib.parse import urlparse + return urlparse(get_settings().external_url).hostname + + +def _rp_name() -> str: + return "WireGUI" + + +def _origin() -> str: + return get_settings().external_url + + +def create_registration_options(user_id: UUID, user_email: str, existing_credentials: list[dict] = None) -> dict: + """Generate WebAuthn registration options to send to the browser. + + Returns a dict with 'options' (serialized for JSON) and 'challenge' (to store in session). + """ + exclude = [] + for cred in (existing_credentials or []): + cred_id = cred.get("credential_id") + if cred_id: + exclude.append(PublicKeyCredentialDescriptor(id=base64url_to_bytes(cred_id))) + + options = generate_registration_options( + rp_id=_rp_id(), + rp_name=_rp_name(), + user_id=str(user_id).encode(), + user_name=user_email, + user_display_name=user_email, + exclude_credentials=exclude, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + ) + + # Serialize for JSON transport + from webauthn.helpers import options_to_json + return { + "options_json": options_to_json(options), + "challenge": bytes_to_base64url(options.challenge), + } + + +def verify_registration(credential_json: str, challenge: str) -> dict: + """Verify a WebAuthn registration response from the browser. + + Returns a dict with credential data to store in MFAMethod.payload. + """ + verification = verify_registration_response( + credential=credential_json, + expected_challenge=base64url_to_bytes(challenge), + expected_rp_id=_rp_id(), + expected_origin=_origin(), + ) + + return { + "credential_id": bytes_to_base64url(verification.credential_id), + "public_key": bytes_to_base64url(verification.credential_public_key), + "sign_count": verification.sign_count, + "attestation_type": verification.fmt if hasattr(verification, 'fmt') else "none", + } + + +def create_authentication_options(credentials: list[dict]) -> dict: + """Generate WebAuthn authentication options for existing credentials. + + Returns a dict with 'options' (serialized) and 'challenge' (to store in session). + """ + allow = [] + for cred in credentials: + cred_id = cred.get("credential_id") + if cred_id: + allow.append(PublicKeyCredentialDescriptor(id=base64url_to_bytes(cred_id))) + + options = generate_authentication_options( + rp_id=_rp_id(), + allow_credentials=allow, + user_verification=UserVerificationRequirement.PREFERRED, + ) + + from webauthn.helpers import options_to_json + return { + "options_json": options_to_json(options), + "challenge": bytes_to_base64url(options.challenge), + } + + +def verify_authentication(credential_json: str, challenge: str, stored_credential: dict) -> dict: + """Verify a WebAuthn authentication response. + + Returns updated credential data (new sign_count). + """ + verification = verify_authentication_response( + credential=credential_json, + expected_challenge=base64url_to_bytes(challenge), + expected_rp_id=_rp_id(), + expected_origin=_origin(), + credential_public_key=base64url_to_bytes(stored_credential["public_key"]), + credential_current_sign_count=stored_credential.get("sign_count", 0), + ) + + return { + "new_sign_count": verification.new_sign_count, + } diff --git a/wiregui/config.py b/wiregui/config.py new file mode 100644 index 0000000..f682813 --- /dev/null +++ b/wiregui/config.py @@ -0,0 +1,55 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="WG_", env_file=".env") + + # Database + database_url: str = "postgresql+asyncpg://wiregui:wiregui@localhost/wiregui" + + # Redis / Valkey + redis_url: str = "redis://localhost:6379/0" + + # Secret key for JWT signing and Fernet encryption + secret_key: str = "change-me-in-production" + + # WireGuard + wg_enabled: bool = False # set True in production (requires NET_ADMIN capability) + wg_interface: str = "wg0" + wg_endpoint_host: str = "localhost" + wg_endpoint_port: int = 51820 + wg_ipv4_network: str = "10.3.2.0/24" + wg_ipv6_network: str = "fd00::3:2:0/120" + wg_dns: str = "1.1.1.1, 1.0.0.1" + wg_mtu: int = 1280 + wg_persistent_keepalive: int = 25 + wg_allowed_ips: str = "0.0.0.0/0, ::/0" + + # Auth + admin_email: str = "admin@localhost" + admin_password: str | None = None + local_auth_enabled: bool = True + magic_link_enabled: bool = True + vpn_session_duration: int = 0 # seconds, 0 = unlimited + + # SMTP + smtp_host: str | None = None + smtp_port: int = 587 + smtp_user: str | None = None + smtp_password: str | None = None + smtp_from: str = "wiregui@localhost" + + # Logging + log_to_file: bool = True # write timestamped log file to logs/ directory + + # App + host: str = "0.0.0.0" + port: int = 13000 + external_url: str = "http://localhost:13000" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/wiregui/db.py b/wiregui/db.py new file mode 100644 index 0000000..4fafc95 --- /dev/null +++ b/wiregui/db.py @@ -0,0 +1,22 @@ +from collections.abc import AsyncGenerator + +from loguru import logger +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from wiregui.config import get_settings + +engine = create_async_engine(get_settings().database_url) +async_session = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_session() -> AsyncGenerator[AsyncSession]: + async with async_session() as session: + yield session + + +async def init_db() -> None: + """Test database connectivity.""" + async with engine.begin() as conn: + await conn.execute(text("SELECT 1")) + logger.info("Database connection OK") diff --git a/wiregui/logging.py b/wiregui/logging.py new file mode 100644 index 0000000..d0be111 --- /dev/null +++ b/wiregui/logging.py @@ -0,0 +1,28 @@ +"""Loguru configuration for WireGUI.""" + +import sys +from datetime import datetime + +from loguru import logger + + +def setup_logging(log_to_file: bool = False) -> None: + """Configure loguru sinks. Call once at startup.""" + # Remove default stderr sink and re-add with our format + logger.remove() + logger.add( + sys.stderr, + format="{time:HH:mm:ss} | {level:<7} | {name}:{function} - {message}", + level="DEBUG", + ) + + if log_to_file: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + logger.add( + f"logs/wiregui_{timestamp}.log", + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<7} | {name}:{function}:{line} - {message}", + level="DEBUG", + rotation="10 MB", + retention="7 days", + ) + logger.info("File logging enabled: logs/wiregui_{}.log", timestamp) diff --git a/wiregui/main.py b/wiregui/main.py new file mode 100644 index 0000000..be2b216 --- /dev/null +++ b/wiregui/main.py @@ -0,0 +1,95 @@ +from loguru import logger +from nicegui import app, ui + +from wiregui.api.v0 import router as api_router +from wiregui.auth.seed import ensure_server_keypair, seed_admin +from wiregui.config import get_settings +from wiregui.db import init_db +from wiregui.logging import setup_logging + +# Mount REST API +app.include_router(api_router, prefix="/api") + + +@app.get("/api/health") +async def health(): + return {"status": "ok"} + +# Import pages so their @ui.page decorators register routes +import wiregui.pages.account # noqa: F401 +import wiregui.pages.admin.devices # noqa: F401 +import wiregui.pages.admin.diagnostics # noqa: F401 +import wiregui.pages.admin.rules # noqa: F401 +import wiregui.pages.admin.settings # noqa: F401 +import wiregui.pages.admin.users # noqa: F401 +import wiregui.pages.auth_magic # noqa: F401 +import wiregui.pages.auth_oidc # noqa: F401 +import wiregui.pages.auth_saml # noqa: F401 +import wiregui.pages.devices # noqa: F401 +import wiregui.pages.home # noqa: F401 +import wiregui.pages.login # noqa: F401 +import wiregui.pages.mfa_challenge # noqa: F401 + + +async def startup() -> None: + settings = get_settings() + setup_logging(log_to_file=settings.log_to_file) + await init_db() + await seed_admin() + await ensure_server_keypair() + + # Register OIDC providers from config + from wiregui.auth.oidc import register_providers + await register_providers() + + from wiregui.tasks import register_task + from wiregui.tasks.oidc_refresh import oidc_refresh_loop + + from wiregui.tasks.connectivity import connectivity_loop + from wiregui.tasks.vpn_session import vpn_session_loop + + # Always run these tasks (even without WG for OIDC refresh and connectivity) + register_task(oidc_refresh_loop(), name="oidc-refresh") + register_task(connectivity_loop(), name="connectivity-check") + + if settings.wg_enabled: + from wiregui.services.firewall import setup_base_tables, setup_masquerade + from wiregui.services.wireguard import configure_interface, ensure_interface + from wiregui.tasks.reconcile import reconcile + from wiregui.tasks.stats import stats_loop + + await ensure_interface() + await configure_interface() + await setup_base_tables() + await setup_masquerade() + await reconcile() + register_task(stats_loop(), name="wg-stats") + register_task(vpn_session_loop(), name="vpn-session-expiry") + else: + logger.info("WireGuard disabled (WG_WG_ENABLED=false) — running in UI-only mode") + + logger.info("WireGUI ready") + + +async def shutdown() -> None: + from wiregui.tasks import cancel_all + await cancel_all() + + +app.on_startup(startup) +app.on_shutdown(shutdown) + + +def main() -> None: + settings = get_settings() + ui.run( + host=settings.host, + port=settings.port, + title="WireGUI", + storage_secret=settings.secret_key, + reload=True, + ) + + +if __name__ in {"__main__", "__mp_main__"}: + main() diff --git a/wiregui/models/__init__.py b/wiregui/models/__init__.py new file mode 100644 index 0000000..1db9bd9 --- /dev/null +++ b/wiregui/models/__init__.py @@ -0,0 +1,21 @@ +"""All SQLModel table models — imported here so Alembic autogenerate can discover them.""" + +from wiregui.models.api_token import ApiToken +from wiregui.models.configuration import Configuration +from wiregui.models.connectivity_check import ConnectivityCheck +from wiregui.models.device import Device +from wiregui.models.mfa_method import MFAMethod +from wiregui.models.oidc_connection import OIDCConnection +from wiregui.models.rule import Rule +from wiregui.models.user import User + +__all__ = [ + "ApiToken", + "Configuration", + "ConnectivityCheck", + "Device", + "MFAMethod", + "OIDCConnection", + "Rule", + "User", +] diff --git a/wiregui/models/api_token.py b/wiregui/models/api_token.py new file mode 100644 index 0000000..17c1f22 --- /dev/null +++ b/wiregui/models/api_token.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from wiregui.utils.time import utcnow +from uuid import UUID, uuid4 + +from sqlmodel import Field, Relationship, SQLModel + + +class ApiToken(SQLModel, table=True): + __tablename__ = "api_tokens" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + token_hash: str = Field(unique=True, index=True) + expires_at: datetime | None = None + + user_id: UUID = Field(foreign_key="users.id", index=True) + + inserted_at: datetime = Field(default_factory=utcnow) + + # Relationships + user: "User" = Relationship(back_populates="api_tokens") + + +from wiregui.models.user import User # noqa: E402, F401 diff --git a/wiregui/models/configuration.py b/wiregui/models/configuration.py new file mode 100644 index 0000000..68b228a --- /dev/null +++ b/wiregui/models/configuration.py @@ -0,0 +1,61 @@ +from datetime import datetime + +from wiregui.utils.time import utcnow +from uuid import UUID, uuid4 + +from sqlmodel import Field, JSON, Column, SQLModel + + +class Configuration(SQLModel, table=True): + __tablename__ = "configurations" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + + # Device management permissions + allow_unprivileged_device_management: bool = Field(default=True) + allow_unprivileged_device_configuration: bool = Field(default=True) + + # Auth + local_auth_enabled: bool = Field(default=True) + disable_vpn_on_oidc_error: bool = Field(default=False) + + # Client defaults + default_client_persistent_keepalive: int = Field(default=25) + default_client_mtu: int = Field(default=1280) + default_client_endpoint: str | None = None + default_client_dns: list[str] = Field( + default_factory=lambda: ["1.1.1.1", "1.0.0.1"], + sa_column=Column(JSON, default=["1.1.1.1", "1.0.0.1"]), + ) + default_client_allowed_ips: list[str] = Field( + default_factory=lambda: ["0.0.0.0/0", "::/0"], + sa_column=Column(JSON, default=["0.0.0.0/0", "::/0"]), + ) + + # Server WireGuard keypair (generated on first startup) + server_private_key: str | None = None + server_public_key: str | None = None + + # VPN session + vpn_session_duration: int = Field(default=0) # seconds, 0 = unlimited + + # Logo + logo_url: str | None = None + logo_type: str | None = None # "url" | "file" | "upload" | None (default) + + # OIDC providers (list of dicts) + # Each: {id, label, scope, response_type, client_id, client_secret, + # discovery_document_uri, redirect_uri, auto_create_users} + openid_connect_providers: list[dict] = Field( + default_factory=list, sa_column=Column(JSON, default=[]) + ) + + # SAML identity providers (list of dicts) + # Each: {id, label, base_url, metadata, sign_requests, sign_metadata, + # signed_assertion_in_resp, signed_envelopes_in_resp, auto_create_users} + saml_identity_providers: list[dict] = Field( + default_factory=list, sa_column=Column(JSON, default=[]) + ) + + inserted_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) diff --git a/wiregui/models/connectivity_check.py b/wiregui/models/connectivity_check.py new file mode 100644 index 0000000..14e2d5b --- /dev/null +++ b/wiregui/models/connectivity_check.py @@ -0,0 +1,18 @@ +from datetime import datetime + +from wiregui.utils.time import utcnow +from uuid import UUID, uuid4 + +from sqlmodel import Field, JSON, Column, SQLModel + + +class ConnectivityCheck(SQLModel, table=True): + __tablename__ = "connectivity_checks" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + url: str + response_code: int | None = None + response_headers: dict | None = Field(default=None, sa_column=Column(JSON)) + response_body: str | None = None + + inserted_at: datetime = Field(default_factory=utcnow) diff --git a/wiregui/models/device.py b/wiregui/models/device.py new file mode 100644 index 0000000..31fb865 --- /dev/null +++ b/wiregui/models/device.py @@ -0,0 +1,52 @@ +from datetime import datetime + +from wiregui.utils.time import utcnow +from uuid import UUID, uuid4 + +from sqlmodel import Field, JSON, Column, Relationship, SQLModel + + +class Device(SQLModel, table=True): + __tablename__ = "devices" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str + description: str | None = None + + public_key: str = Field(unique=True, index=True) + preshared_key: str | None = None # encrypted at application level + + # Client config: use server defaults or per-device overrides + use_default_allowed_ips: bool = Field(default=True) + use_default_dns: bool = Field(default=True) + use_default_endpoint: bool = Field(default=True) + use_default_mtu: bool = Field(default=True) + use_default_persistent_keepalive: bool = Field(default=True) + + # Per-device overrides (used when use_default_* is False) + endpoint: str | None = None + mtu: int | None = None + persistent_keepalive: int | None = None + allowed_ips: list[str] = Field(default_factory=list, sa_column=Column(JSON, default=[])) + dns: list[str] = Field(default_factory=list, sa_column=Column(JSON, default=[])) + + # Assigned tunnel addresses + ipv4: str | None = Field(default=None, unique=True) + ipv6: str | None = Field(default=None, unique=True) + + # Peer stats (updated periodically from WireGuard) + remote_ip: str | None = None + rx_bytes: int | None = None + tx_bytes: int | None = None + latest_handshake: datetime | None = None + + user_id: UUID = Field(foreign_key="users.id", index=True) + + inserted_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) + + # Relationships + user: "User" = Relationship(back_populates="devices") + + +from wiregui.models.user import User # noqa: E402, F401 diff --git a/wiregui/models/mfa_method.py b/wiregui/models/mfa_method.py new file mode 100644 index 0000000..e45afa5 --- /dev/null +++ b/wiregui/models/mfa_method.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from wiregui.utils.time import utcnow +from uuid import UUID, uuid4 + +from sqlmodel import Field, JSON, Column, Relationship, SQLModel + + +class MFAMethod(SQLModel, table=True): + __tablename__ = "mfa_methods" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str + type: str # "totp" | "native" | "portable" + payload: dict = Field(default_factory=dict, sa_column=Column(JSON)) # encrypted at app level + last_used_at: datetime | None = None + + user_id: UUID = Field(foreign_key="users.id", index=True) + + inserted_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) + + # Relationships + user: "User" = Relationship(back_populates="mfa_methods") + + +from wiregui.models.user import User # noqa: E402, F401 diff --git a/wiregui/models/oidc_connection.py b/wiregui/models/oidc_connection.py new file mode 100644 index 0000000..9ff5b8b --- /dev/null +++ b/wiregui/models/oidc_connection.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from wiregui.utils.time import utcnow +from uuid import UUID, uuid4 + +from sqlmodel import Field, JSON, Column, Relationship, SQLModel + + +class OIDCConnection(SQLModel, table=True): + __tablename__ = "oidc_connections" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + provider: str + refresh_token: str | None = None # encrypted at application level + refresh_response: dict | None = Field(default=None, sa_column=Column(JSON)) + refreshed_at: datetime | None = None + + user_id: UUID = Field(foreign_key="users.id", index=True) + + inserted_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) + + # Relationships + user: "User" = Relationship(back_populates="oidc_connections") + + +from wiregui.models.user import User # noqa: E402, F401 diff --git a/wiregui/models/rule.py b/wiregui/models/rule.py new file mode 100644 index 0000000..4939212 --- /dev/null +++ b/wiregui/models/rule.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from wiregui.utils.time import utcnow +from uuid import UUID, uuid4 + +from sqlmodel import Field, Relationship, SQLModel + + +class Rule(SQLModel, table=True): + __tablename__ = "rules" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + action: str = Field(default="drop") # "drop" | "accept" + destination: str # CIDR notation, e.g. "10.0.0.0/8" or "0.0.0.0/0" + port_type: str | None = None # "tcp" | "udp" | None (any) + port_range: str | None = None # e.g. "80-443" or "22" or None (any) + + user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True) + + inserted_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) + + # Relationships + user: "User" = Relationship(back_populates="rules") + + +from wiregui.models.user import User # noqa: E402, F401 diff --git a/wiregui/models/user.py b/wiregui/models/user.py new file mode 100644 index 0000000..171bc78 --- /dev/null +++ b/wiregui/models/user.py @@ -0,0 +1,41 @@ +from datetime import datetime + +from wiregui.utils.time import utcnow +from uuid import UUID, uuid4 + +from sqlmodel import Field, Relationship, SQLModel + + +class User(SQLModel, table=True): + __tablename__ = "users" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + email: str = Field(unique=True, index=True) + password_hash: str | None = None + role: str = Field(default="unprivileged") # "admin" | "unprivileged" + + last_signed_in_at: datetime | None = None + last_signed_in_method: str | None = None + + sign_in_token_hash: str | None = None + sign_in_token_created_at: datetime | None = None + + disabled_at: datetime | None = None + + inserted_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) + + # Relationships + devices: list["Device"] = Relationship(back_populates="user") + oidc_connections: list["OIDCConnection"] = Relationship(back_populates="user") + api_tokens: list["ApiToken"] = Relationship(back_populates="user") + mfa_methods: list["MFAMethod"] = Relationship(back_populates="user") + rules: list["Rule"] = Relationship(back_populates="user") + + +# Avoid circular imports — these are resolved at runtime by SQLModel +from wiregui.models.api_token import ApiToken # noqa: E402, F401 +from wiregui.models.device import Device # noqa: E402, F401 +from wiregui.models.mfa_method import MFAMethod # noqa: E402, F401 +from wiregui.models.oidc_connection import OIDCConnection # noqa: E402, F401 +from wiregui.models.rule import Rule # noqa: E402, F401 diff --git a/wiregui/pages/__init__.py b/wiregui/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wiregui/pages/account.py b/wiregui/pages/account.py new file mode 100644 index 0000000..8448b0a --- /dev/null +++ b/wiregui/pages/account.py @@ -0,0 +1,388 @@ +"""User account page — password change, MFA management, API tokens.""" + +from uuid import UUID + +from loguru import logger +from nicegui import app, ui +from sqlmodel import select + +import json + +from wiregui.auth.api_token import generate_api_token +from wiregui.auth.mfa import generate_totp_qr_svg, generate_totp_secret, get_totp_uri, verify_totp_code +from wiregui.auth.passwords import hash_password, verify_password +from wiregui.auth.webauthn import create_registration_options, verify_registration +from wiregui.db import async_session +from wiregui.models.api_token import ApiToken +from wiregui.models.mfa_method import MFAMethod +from wiregui.models.oidc_connection import OIDCConnection +from wiregui.models.user import User +from wiregui.pages.layout import layout +from wiregui.utils.time import utcnow + + +@ui.page("/account") +async def account_page(): + if not app.storage.user.get("authenticated"): + return ui.navigate.to("/login") + + layout() + user_id = UUID(app.storage.user["user_id"]) + + async with async_session() as session: + user = await session.get(User, user_id) + + with ui.column().classes("w-full p-4"): + ui.label("Account Settings").classes("text-h5 q-mb-md") + + with ui.tabs().classes("w-full") as tabs: + profile_tab = ui.tab("Profile") + mfa_tab = ui.tab("Two-Factor Auth") + tokens_tab = ui.tab("API Tokens") + + with ui.tab_panels(tabs, value=profile_tab).classes("w-full"): + + # === Profile === + with ui.tab_panel(profile_tab): + with ui.card().classes("w-full"): + ui.label("Account Details").classes("text-subtitle1 text-bold") + ui.separator() + with ui.grid(columns=2).classes("w-full gap-2 q-pa-sm"): + ui.label("Email:").classes("text-bold") + ui.label(user.email) + ui.label("Role:").classes("text-bold") + ui.label(user.role) + ui.label("Last Sign-in:").classes("text-bold") + ui.label(str(user.last_signed_in_at)[:19] if user.last_signed_in_at else "-") + ui.label("Method:").classes("text-bold") + ui.label(user.last_signed_in_method or "-") + + with ui.card().classes("w-full q-mt-md"): + ui.label("Change Password").classes("text-subtitle1 text-bold") + ui.separator() + + current_pw = ui.input("Current Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") + new_pw = ui.input("New Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") + confirm_pw = ui.input("Confirm New Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") + + async def change_password(): + if not current_pw.value or not new_pw.value: + ui.notify("Fill in all password fields", type="negative") + return + if new_pw.value != confirm_pw.value: + ui.notify("New passwords do not match", type="negative") + return + if len(new_pw.value) < 8: + ui.notify("Password must be at least 8 characters", type="negative") + return + + async with async_session() as session: + u = await session.get(User, user_id) + if not verify_password(current_pw.value, u.password_hash): + ui.notify("Current password is incorrect", type="negative") + return + u.password_hash = hash_password(new_pw.value) + session.add(u) + await session.commit() + + logger.info("Password changed for {}", user.email) + ui.notify("Password changed", type="positive") + current_pw.value = "" + new_pw.value = "" + confirm_pw.value = "" + + ui.button("Change Password", on_click=change_password).props("color=primary").classes("q-mt-sm") + + # OIDC connections + async with async_session() as session: + oidc_conns = (await session.execute( + select(OIDCConnection).where(OIDCConnection.user_id == user_id) + )).scalars().all() + + if oidc_conns: + with ui.card().classes("w-full q-mt-md"): + ui.label("Connected SSO Providers").classes("text-subtitle1 text-bold") + ui.separator() + for conn in oidc_conns: + with ui.row().classes("w-full items-center justify-between q-pa-xs"): + ui.label(f"{conn.provider}").classes("text-bold") + ui.label(f"Last refreshed: {str(conn.refreshed_at)[:19] if conn.refreshed_at else 'Never'}") + + # === MFA === + with ui.tab_panel(mfa_tab): + await _render_mfa_panel(user_id, user.email) + + # === API Tokens === + with ui.tab_panel(tokens_tab): + await _render_tokens_panel(user_id) + + +async def _render_mfa_panel(user_id: UUID, email: str): + """Render the MFA management tab.""" + async def load_methods(): + async with async_session() as session: + result = await session.execute( + select(MFAMethod).where(MFAMethod.user_id == user_id).order_by(MFAMethod.inserted_at) + ) + return result.scalars().all() + + async def refresh_methods(): + methods = await load_methods() + methods_container.clear() + with methods_container: + if methods: + for m in methods: + with ui.row().classes("w-full items-center justify-between q-pa-xs"): + with ui.row().classes("items-center gap-2"): + ui.icon("security").props("color=primary") + ui.label(m.name).classes("text-bold") + ui.label(f"({m.type})").classes("text-caption text-grey-7") + with ui.row().classes("items-center gap-2"): + ui.label(f"Last used: {str(m.last_used_at)[:19] if m.last_used_at else 'Never'}").classes("text-caption") + ui.button(icon="delete", on_click=lambda mid=m.id: delete_method(mid)).props("flat dense color=negative") + ui.separator() + else: + ui.label("No MFA methods configured.").classes("text-caption text-grey-7 q-pa-sm") + + async def delete_method(method_id): + async with async_session() as session: + m = await session.get(MFAMethod, method_id) + if m and m.user_id == user_id: + await session.delete(m) + await session.commit() + logger.info("MFA method deleted for user {}", email) + ui.notify("MFA method removed") + await refresh_methods() + + # Registration state + registration = {"secret": None} + + def start_registration(): + secret = generate_totp_secret() + registration["secret"] = secret + uri = get_totp_uri(secret, email) + svg = generate_totp_qr_svg(uri) + + reg_container.clear() + with reg_container: + ui.label("Scan this QR code with your authenticator app:").classes("text-body2") + ui.html(svg).classes("w-64 q-my-sm") + ui.label(f"Or enter this secret manually: {secret}").classes("text-caption font-mono") + reg_name_input = ui.input("Method Name", value="Authenticator").props("outlined dense").classes("w-full") + reg_code_input = ui.input("Verification Code", placeholder="Enter 6-digit code").props("outlined dense maxlength=6").classes("w-full") + + async def verify_and_save(): + code = reg_code_input.value.strip() + name = reg_name_input.value.strip() or "Authenticator" + if not verify_totp_code(registration["secret"], code): + ui.notify("Invalid code — check your authenticator", type="negative") + return + + async with async_session() as session: + method = MFAMethod( + name=name, + type="totp", + payload={"secret": registration["secret"]}, + user_id=user_id, + ) + session.add(method) + await session.commit() + + logger.info("MFA TOTP registered for {}", email) + ui.notify("MFA method added!", type="positive") + registration["secret"] = None + reg_container.clear() + await refresh_methods() + + ui.button("Verify & Save", on_click=verify_and_save).props("color=primary").classes("q-mt-sm") + ui.button("Cancel", on_click=lambda: reg_container.clear()).props("flat") + + with ui.card().classes("w-full"): + ui.label("Two-Factor Authentication Methods").classes("text-subtitle1 text-bold") + ui.separator() + + methods_container = ui.column().classes("w-full") + await refresh_methods() + + with ui.row().classes("q-mt-sm gap-2"): + ui.button("Add TOTP Method", icon="add", on_click=start_registration).props("outline") + ui.button("Add Security Key", icon="key", on_click=lambda: start_webauthn_registration()).props("outline") + + reg_container = ui.column().classes("w-full q-mt-md") + webauthn_state = {"challenge": None} + + async def start_webauthn_registration(): + # Get existing webauthn credentials to exclude + existing = [] + async with async_session() as session: + from sqlmodel import select as sel + result = await session.execute( + sel(MFAMethod).where(MFAMethod.user_id == user_id, MFAMethod.type.in_(["native", "portable"])) + ) + for m in result.scalars().all(): + existing.append(m.payload) + + try: + reg_data = create_registration_options(user_id, email, existing) + except Exception as e: + ui.notify(f"WebAuthn not available: {e}", type="negative") + return + + webauthn_state["challenge"] = reg_data["challenge"] + options_json = reg_data["options_json"] + + # Call browser's navigator.credentials.create() via JavaScript + js = f""" + async function() {{ + try {{ + const options = JSON.parse('{options_json}'); + // Convert base64url strings to ArrayBuffers + options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)); + options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)); + if (options.excludeCredentials) {{ + options.excludeCredentials = options.excludeCredentials.map(c => ({{ + ...c, + id: Uint8Array.from(atob(c.id.replace(/-/g,'+').replace(/_/g,'/')), ch => ch.charCodeAt(0)) + }})); + }} + const credential = await navigator.credentials.create({{publicKey: options}}); + // Serialize the response + const response = {{ + id: credential.id, + rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), + type: credential.type, + response: {{ + attestationObject: btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), + clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), + }}, + }}; + return JSON.stringify(response); + }} catch(e) {{ + return JSON.stringify({{"error": e.message}}); + }} + }} + """ + result = await ui.run_javascript(f"({js})()") + await _handle_webauthn_response(result) + + async def _handle_webauthn_response(result_json: str): + try: + result = json.loads(result_json) + except (json.JSONDecodeError, TypeError): + ui.notify("WebAuthn response error", type="negative") + return + + if "error" in result: + ui.notify(f"WebAuthn failed: {result['error']}", type="negative") + return + + challenge = webauthn_state.get("challenge") + if not challenge: + ui.notify("No pending WebAuthn challenge", type="negative") + return + + try: + credential_data = verify_registration(result_json, challenge) + except Exception as e: + ui.notify(f"Verification failed: {e}", type="negative") + return + + async with async_session() as session: + method = MFAMethod( + name="Security Key", + type="portable", + payload=credential_data, + user_id=user_id, + ) + session.add(method) + await session.commit() + + logger.info("WebAuthn key registered for {}", email) + ui.notify("Security key registered!", type="positive") + webauthn_state["challenge"] = None + await refresh_methods() + + +async def _render_tokens_panel(user_id: UUID): + """Render the API tokens tab.""" + async def load_tokens(): + async with async_session() as session: + result = await session.execute( + select(ApiToken).where(ApiToken.user_id == user_id).order_by(ApiToken.inserted_at.desc()) + ) + return result.scalars().all() + + async def refresh_tokens(): + tokens = await load_tokens() + token_table.rows = [ + { + "id": str(t.id), + "created": str(t.inserted_at)[:19], + "expires": str(t.expires_at)[:19] if t.expires_at else "Never", + "status": "Expired" if t.expires_at and t.expires_at < utcnow() else "Active", + } + for t in tokens + ] + token_table.update() + + async def create_token(): + from datetime import timedelta + days = int(token_days.value) if token_days.value else 30 + + plaintext, token_hash = generate_api_token() + expires_at = utcnow() + timedelta(days=days) if days > 0 else None + + async with async_session() as session: + token = ApiToken(token_hash=token_hash, expires_at=expires_at, user_id=user_id) + session.add(token) + await session.commit() + + logger.info("API token created (expires in {} days)", days) + + # Show the token once + with ui.dialog(value=True) as token_dialog: + with ui.card().classes("w-96"): + ui.label("API Token Created").classes("text-h6") + ui.label("Copy this token now — it won't be shown again.").classes("text-caption text-negative") + ui.input(value=plaintext).props("readonly outlined dense").classes("w-full font-mono q-mt-sm") + ui.button("Close", on_click=token_dialog.close).props("flat").classes("w-full q-mt-sm") + + await refresh_tokens() + + async def delete_token(token_id: str): + async with async_session() as session: + t = await session.get(ApiToken, UUID(token_id)) + if t and t.user_id == user_id: + await session.delete(t) + await session.commit() + ui.notify("Token deleted") + await refresh_tokens() + + with ui.card().classes("w-full"): + ui.label("API Tokens").classes("text-subtitle1 text-bold") + ui.separator() + ui.label("Use API tokens for programmatic access to the REST API.").classes("text-caption text-grey-7") + + token_columns = [ + {"name": "created", "label": "Created", "field": "created", "align": "left"}, + {"name": "expires", "label": "Expires", "field": "expires", "align": "left"}, + {"name": "status", "label": "Status", "field": "status", "align": "left"}, + {"name": "actions", "label": "", "field": "id", "align": "center"}, + ] + token_table = ui.table(columns=token_columns, rows=[], row_key="id").classes("w-full") + token_table.add_slot( + "body-cell-actions", + ''' + + + + ''', + ) + token_table.on("delete", lambda e: delete_token(e.args)) + + with ui.row().classes("items-center gap-2 q-mt-sm"): + token_days = ui.input("Expires in (days)", value="30").props("outlined dense").classes("w-40") + ui.button("Create Token", icon="add", on_click=create_token).props("color=primary") + + await refresh_tokens() diff --git a/wiregui/pages/admin/__init__.py b/wiregui/pages/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wiregui/pages/admin/devices.py b/wiregui/pages/admin/devices.py new file mode 100644 index 0000000..7cfe424 --- /dev/null +++ b/wiregui/pages/admin/devices.py @@ -0,0 +1,350 @@ +"""Admin device management — view and manage all devices across all users.""" + +import io +from uuid import UUID + +import qrcode +import qrcode.image.svg +from loguru import logger +from nicegui import app, ui +from sqlmodel import select + +from wiregui.config import get_settings +from wiregui.db import async_session +from wiregui.models.device import Device +from wiregui.models.user import User +from wiregui.pages.layout import layout +from wiregui.services.events import on_device_created, on_device_deleted, on_device_updated +from wiregui.utils.crypto import generate_keypair, generate_preshared_key +from wiregui.utils.network import allocate_ipv4, allocate_ipv6 +from wiregui.utils.server_key import get_server_public_key +from wiregui.utils.wg_conf import build_client_config + + +def _guard(): + if not app.storage.user.get("authenticated") or app.storage.user.get("role") != "admin": + ui.navigate.to("/login") + return False + return True + + +def _format_bytes(b: int | None) -> str: + if b is None: + return "-" + for unit in ("B", "KB", "MB", "GB", "TB"): + if b < 1024: + return f"{b:.1f} {unit}" + b /= 1024 + return f"{b:.1f} PB" + + +@ui.page("/admin/devices") +async def admin_devices_page(): + if not _guard(): + return + + layout() + + # Load users for filter and create form + async with async_session() as session: + users = (await session.execute(select(User).order_by(User.email))).scalars().all() + user_map = {str(u.id): u.email for u in users} + + async def load_devices(user_filter: str | None = None) -> list[dict]: + async with async_session() as session: + stmt = select(Device).order_by(Device.inserted_at.desc()) + if user_filter and user_filter != "all": + stmt = stmt.where(Device.user_id == UUID(user_filter)) + result = await session.execute(stmt) + return [ + { + "id": str(d.id), + "name": d.name, + "user": user_map.get(str(d.user_id), "Unknown"), + "ipv4": d.ipv4 or "-", + "ipv6": d.ipv6 or "-", + "public_key": d.public_key[:16] + "...", + "rx": _format_bytes(d.rx_bytes), + "tx": _format_bytes(d.tx_bytes), + "handshake": str(d.latest_handshake)[:19] if d.latest_handshake else "-", + } + for d in result.scalars().all() + ] + + async def refresh_table(): + table.rows = await load_devices(user_filter_select.value) + table.update() + + async def on_filter_change(): + await refresh_table() + + # --- Create device --- + async def create_device(): + name = create_name.value.strip() + owner_id = create_user_select.value + if not name or not owner_id: + ui.notify("Name and user are required", type="negative") + return + + try: + settings = get_settings() + private_key, public_key = generate_keypair() + psk = generate_preshared_key() + + async with async_session() as session: + ipv4 = await allocate_ipv4(session, settings.wg_ipv4_network) + ipv6 = await allocate_ipv6(session, settings.wg_ipv6_network) + + device = Device( + name=name, + description=create_desc.value.strip() or None, + public_key=public_key, + preshared_key=psk, + ipv4=ipv4, + ipv6=ipv6, + user_id=UUID(owner_id), + # Apply config overrides if not using defaults + use_default_allowed_ips=create_use_default_ips.value, + use_default_dns=create_use_default_dns.value, + use_default_endpoint=create_use_default_endpoint.value, + use_default_mtu=create_use_default_mtu.value, + use_default_persistent_keepalive=create_use_default_keepalive.value, + endpoint=create_endpoint.value.strip() or None if not create_use_default_endpoint.value else None, + dns=([s.strip() for s in create_dns.value.split(",") if s.strip()] + if not create_use_default_dns.value and create_dns.value else []), + mtu=int(create_mtu.value) if not create_use_default_mtu.value and create_mtu.value else None, + persistent_keepalive=(int(create_keepalive.value) + if not create_use_default_keepalive.value and create_keepalive.value else None), + allowed_ips=([s.strip() for s in create_allowed_ips.value.split(",") if s.strip()] + if not create_use_default_ips.value and create_allowed_ips.value else []), + ) + session.add(device) + await session.commit() + await session.refresh(device) + + logger.info("Admin created device: {} for {}", device.name, user_map.get(owner_id)) + await on_device_created(device) + + # Show config + server_pubkey = await get_server_public_key() + config_text = build_client_config(device, private_key, server_pubkey) + _show_config_dialog(device.name, config_text) + + create_dialog.close() + _reset_create_form() + await refresh_table() + except Exception as e: + logger.error("Failed to create device: {}", e) + ui.notify(f"Error: {e}", type="negative") + + def _reset_create_form(): + create_name.value = "" + create_desc.value = "" + create_use_default_ips.value = True + create_use_default_dns.value = True + create_use_default_endpoint.value = True + create_use_default_mtu.value = True + create_use_default_keepalive.value = True + + # --- Edit device --- + edit_device_id = {"value": None} + + async def open_edit(device_id: str): + async with async_session() as session: + device = await session.get(Device, UUID(device_id)) + if not device: + return + + edit_device_id["value"] = device_id + edit_name.value = device.name + edit_desc.value = device.description or "" + edit_use_default_ips.value = device.use_default_allowed_ips + edit_use_default_dns.value = device.use_default_dns + edit_use_default_endpoint.value = device.use_default_endpoint + edit_use_default_mtu.value = device.use_default_mtu + edit_use_default_keepalive.value = device.use_default_persistent_keepalive + edit_endpoint.value = device.endpoint or "" + edit_dns.value = ", ".join(device.dns) if device.dns else "" + edit_mtu.value = str(device.mtu) if device.mtu else "" + edit_keepalive.value = str(device.persistent_keepalive) if device.persistent_keepalive else "" + edit_allowed_ips.value = ", ".join(device.allowed_ips) if device.allowed_ips else "" + edit_dialog.open() + + async def save_edit(): + did = edit_device_id["value"] + if not did: + return + + async with async_session() as session: + device = await session.get(Device, UUID(did)) + if not device: + return + + device.name = edit_name.value.strip() + device.description = edit_desc.value.strip() or None + device.use_default_allowed_ips = edit_use_default_ips.value + device.use_default_dns = edit_use_default_dns.value + device.use_default_endpoint = edit_use_default_endpoint.value + device.use_default_mtu = edit_use_default_mtu.value + device.use_default_persistent_keepalive = edit_use_default_keepalive.value + + if not device.use_default_endpoint: + device.endpoint = edit_endpoint.value.strip() or None + if not device.use_default_dns: + device.dns = [s.strip() for s in edit_dns.value.split(",") if s.strip()] + if not device.use_default_mtu: + device.mtu = int(edit_mtu.value) if edit_mtu.value else None + if not device.use_default_persistent_keepalive: + device.persistent_keepalive = int(edit_keepalive.value) if edit_keepalive.value else None + if not device.use_default_allowed_ips: + device.allowed_ips = [s.strip() for s in edit_allowed_ips.value.split(",") if s.strip()] + + session.add(device) + await session.commit() + await session.refresh(device) + await on_device_updated(device) + + logger.info("Admin updated device: {}", edit_name.value) + ui.notify("Device updated") + edit_dialog.close() + await refresh_table() + + # --- Delete device --- + async def delete_device(device_id: str): + async with async_session() as session: + device = await session.get(Device, UUID(device_id)) + if device: + await session.delete(device) + await session.commit() + logger.info("Admin deleted device: {}", device.name) + await on_device_deleted(device) + ui.notify(f"Deleted {device.name}") + await refresh_table() + + # --- Page content --- + with ui.column().classes("w-full p-4"): + with ui.row().classes("w-full items-center justify-between"): + ui.label("All Devices").classes("text-h5") + with ui.row().classes("items-center gap-4"): + filter_options = {"all": "All Users"} + filter_options.update(user_map) + user_filter_select = ui.select( + filter_options, value="all", label="Filter by User", + on_change=lambda: on_filter_change(), + ).props("outlined dense").classes("w-48") + ui.button("Add Device", icon="add", on_click=lambda: create_dialog.open()).props("color=primary") + + columns = [ + {"name": "name", "label": "Name", "field": "name", "align": "left", "sortable": True}, + {"name": "user", "label": "User", "field": "user", "align": "left", "sortable": True}, + {"name": "ipv4", "label": "IPv4", "field": "ipv4", "align": "left"}, + {"name": "ipv6", "label": "IPv6", "field": "ipv6", "align": "left"}, + {"name": "public_key", "label": "Public Key", "field": "public_key", "align": "left"}, + {"name": "rx", "label": "RX", "field": "rx", "align": "right"}, + {"name": "tx", "label": "TX", "field": "tx", "align": "right"}, + {"name": "handshake", "label": "Last Handshake", "field": "handshake", "align": "left"}, + {"name": "actions", "label": "", "field": "id", "align": "center"}, + ] + table = ui.table(columns=columns, rows=[], row_key="id").classes("w-full") + table.add_slot( + "body-cell-actions", + ''' + + + + + ''', + ) + table.on("edit", lambda e: open_edit(e.args)) + table.on("delete", lambda e: delete_device(e.args)) + + # --- Create dialog (full form) --- + with ui.dialog() as create_dialog: + with ui.card().classes("w-[600px]"): + ui.label("New Device").classes("text-h6") + + create_user_select = ui.select( + user_map, value=list(user_map.keys())[0] if user_map else None, + label="Owner", + ).props("outlined dense").classes("w-full") + + create_name = ui.input("Device Name").props("outlined dense").classes("w-full") + create_desc = ui.input("Description (optional)").props("outlined dense").classes("w-full") + + ui.separator().classes("q-my-sm") + ui.label("Configuration Overrides").classes("text-subtitle2") + + with ui.grid(columns=2).classes("w-full gap-2"): + create_use_default_ips = ui.switch("Use default Allowed IPs", value=True) + create_allowed_ips = ui.input("Allowed IPs", placeholder="0.0.0.0/0, ::/0").props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_ips, "value", backward=lambda v: not v) + + create_use_default_dns = ui.switch("Use default DNS", value=True) + create_dns = ui.input("DNS Servers", placeholder="1.1.1.1, 1.0.0.1").props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_dns, "value", backward=lambda v: not v) + + create_use_default_endpoint = ui.switch("Use default Endpoint", value=True) + create_endpoint = ui.input("Endpoint", placeholder="vpn.example.com").props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_endpoint, "value", backward=lambda v: not v) + + create_use_default_mtu = ui.switch("Use default MTU", value=True) + create_mtu = ui.input("MTU", placeholder="1280").props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_mtu, "value", backward=lambda v: not v) + + create_use_default_keepalive = ui.switch("Use default Keepalive", value=True) + create_keepalive = ui.input("Persistent Keepalive", placeholder="25").props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_keepalive, "value", backward=lambda v: not v) + + with ui.row().classes("w-full justify-end q-mt-sm"): + ui.button("Cancel", on_click=create_dialog.close).props("flat") + ui.button("Create", on_click=create_device).props("color=primary") + + # --- Edit dialog (full form) --- + with ui.dialog() as edit_dialog: + with ui.card().classes("w-[600px]"): + ui.label("Edit Device").classes("text-h6") + + edit_name = ui.input("Device Name").props("outlined dense").classes("w-full") + edit_desc = ui.input("Description").props("outlined dense").classes("w-full") + + ui.separator().classes("q-my-sm") + ui.label("Configuration Overrides").classes("text-subtitle2") + + with ui.grid(columns=2).classes("w-full gap-2"): + edit_use_default_ips = ui.switch("Use default Allowed IPs", value=True) + edit_allowed_ips = ui.input("Allowed IPs").props("outlined dense").classes("w-full").bind_enabled_from(edit_use_default_ips, "value", backward=lambda v: not v) + + edit_use_default_dns = ui.switch("Use default DNS", value=True) + edit_dns = ui.input("DNS Servers").props("outlined dense").classes("w-full").bind_enabled_from(edit_use_default_dns, "value", backward=lambda v: not v) + + edit_use_default_endpoint = ui.switch("Use default Endpoint", value=True) + edit_endpoint = ui.input("Endpoint").props("outlined dense").classes("w-full").bind_enabled_from(edit_use_default_endpoint, "value", backward=lambda v: not v) + + edit_use_default_mtu = ui.switch("Use default MTU", value=True) + edit_mtu = ui.input("MTU").props("outlined dense").classes("w-full").bind_enabled_from(edit_use_default_mtu, "value", backward=lambda v: not v) + + edit_use_default_keepalive = ui.switch("Use default Keepalive", value=True) + edit_keepalive = ui.input("Persistent Keepalive").props("outlined dense").classes("w-full").bind_enabled_from(edit_use_default_keepalive, "value", backward=lambda v: not v) + + with ui.row().classes("w-full justify-end q-mt-sm"): + ui.button("Cancel", on_click=edit_dialog.close).props("flat") + ui.button("Save", on_click=save_edit).props("color=primary") + + await refresh_table() + + # Auto-refresh stats every 30 seconds + ui.timer(30, refresh_table) + + +def _show_config_dialog(device_name: str, config_text: str): + with ui.dialog(value=True) as dialog: + with ui.card().classes("w-96"): + ui.label(f"Config for {device_name}").classes("text-h6") + ui.label("Save this — the private key won't be shown again.").classes("text-caption text-negative") + ui.textarea(value=config_text).props("readonly outlined").classes("w-full font-mono text-xs q-mt-sm").style("min-height: 200px") + try: + qr = qrcode.make(config_text, image_factory=qrcode.image.svg.SvgPathImage) + buf = io.BytesIO() + qr.save(buf) + ui.html(buf.getvalue().decode()).classes("w-full q-mt-sm") + except Exception: + pass + ui.button("Download .conf", on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf")).props("color=primary outline").classes("w-full q-mt-sm") + ui.button("Close", on_click=dialog.close).props("flat").classes("w-full") diff --git a/wiregui/pages/admin/diagnostics.py b/wiregui/pages/admin/diagnostics.py new file mode 100644 index 0000000..777c286 --- /dev/null +++ b/wiregui/pages/admin/diagnostics.py @@ -0,0 +1,162 @@ +"""Admin diagnostics page — connectivity checks, WG status, peer stats.""" + +from nicegui import app, ui +from sqlmodel import select + +from wiregui.config import get_settings +from wiregui.db import async_session +from wiregui.models.connectivity_check import ConnectivityCheck +from wiregui.models.device import Device +from wiregui.pages.layout import layout +from wiregui.services import notifications + + +def _guard(): + if not app.storage.user.get("authenticated") or app.storage.user.get("role") != "admin": + ui.navigate.to("/login") + return False + return True + + +def _format_bytes(b: int | None) -> str: + if b is None: + return "-" + for unit in ("B", "KB", "MB", "GB", "TB"): + if b < 1024: + return f"{b:.1f} {unit}" + b /= 1024 + return f"{b:.1f} PB" + + +@ui.page("/admin/diagnostics") +async def diagnostics_page(): + if not _guard(): + return + + layout() + settings = get_settings() + + with ui.column().classes("w-full p-4"): + ui.label("Diagnostics").classes("text-h5 q-mb-md") + + # --- WireGuard Status --- + with ui.card().classes("w-full"): + ui.label("WireGuard Interface").classes("text-subtitle1 text-bold") + ui.separator() + + with ui.grid(columns=2).classes("w-full gap-2 q-pa-sm"): + ui.label("Interface:").classes("text-bold") + ui.label(settings.wg_interface) + + ui.label("Status:").classes("text-bold") + ui.label("Enabled" if settings.wg_enabled else "Disabled (UI-only mode)").classes( + "text-positive" if settings.wg_enabled else "text-warning" + ) + + ui.label("IPv4 Network:").classes("text-bold") + ui.label(settings.wg_ipv4_network) + + ui.label("IPv6 Network:").classes("text-bold") + ui.label(settings.wg_ipv6_network) + + ui.label("Endpoint:").classes("text-bold") + ui.label(f"{settings.wg_endpoint_host}:{settings.wg_endpoint_port}") + + # --- Active Peers --- + with ui.card().classes("w-full q-mt-md"): + ui.label("Active Peers (from DB)").classes("text-subtitle1 text-bold") + ui.separator() + + async with async_session() as session: + result = await session.execute( + select(Device).where(Device.latest_handshake.is_not(None)).order_by(Device.latest_handshake.desc()) + ) + active_devices = result.scalars().all() + + if active_devices: + peer_columns = [ + {"name": "name", "label": "Name", "field": "name", "align": "left"}, + {"name": "public_key", "label": "Public Key", "field": "public_key", "align": "left"}, + {"name": "ipv4", "label": "IPv4", "field": "ipv4", "align": "left"}, + {"name": "endpoint", "label": "Remote IP", "field": "endpoint", "align": "left"}, + {"name": "handshake", "label": "Last Handshake", "field": "handshake", "align": "left"}, + {"name": "rx", "label": "RX", "field": "rx", "align": "right"}, + {"name": "tx", "label": "TX", "field": "tx", "align": "right"}, + ] + peer_rows = [ + { + "name": d.name, + "public_key": d.public_key[:16] + "...", + "ipv4": d.ipv4 or "-", + "endpoint": d.remote_ip or "-", + "handshake": str(d.latest_handshake)[:19] if d.latest_handshake else "-", + "rx": _format_bytes(d.rx_bytes), + "tx": _format_bytes(d.tx_bytes), + } + for d in active_devices + ] + ui.table(columns=peer_columns, rows=peer_rows, row_key="name").classes("w-full") + else: + ui.label("No active peers with recent handshakes.").classes("text-caption text-grey-7 q-pa-sm") + + # --- Connectivity Checks --- + with ui.card().classes("w-full q-mt-md"): + ui.label("WAN Connectivity Checks").classes("text-subtitle1 text-bold") + ui.separator() + + async with async_session() as session: + result = await session.execute( + select(ConnectivityCheck).order_by(ConnectivityCheck.inserted_at.desc()).limit(20) + ) + checks = result.scalars().all() + + if checks: + check_columns = [ + {"name": "time", "label": "Checked At", "field": "time", "align": "left"}, + {"name": "url", "label": "URL", "field": "url", "align": "left"}, + {"name": "status", "label": "Status", "field": "status", "align": "center"}, + {"name": "body", "label": "Response", "field": "body", "align": "left"}, + ] + check_rows = [ + { + "time": str(c.inserted_at)[:19], + "url": c.url, + "status": str(c.response_code or "Error"), + "body": (c.response_body or "")[:50], + } + for c in checks + ] + ui.table(columns=check_columns, rows=check_rows, row_key="time").classes("w-full") + else: + ui.label("No connectivity checks recorded yet.").classes("text-caption text-grey-7 q-pa-sm") + + # --- Notifications --- + with ui.card().classes("w-full q-mt-md"): + ui.label("System Notifications").classes("text-subtitle1 text-bold") + ui.separator() + + notifs = notifications.current() + if notifs: + for n in notifs: + color = {"error": "negative", "warning": "warning", "info": "info"}.get(n.severity, "grey") + with ui.row().classes("w-full items-center q-pa-xs"): + ui.icon("error" if n.severity == "error" else "warning" if n.severity == "warning" else "info").props(f"color={color}") + ui.label(f"{n.timestamp.strftime('%H:%M:%S')} — {n.message}").classes("text-sm") + if n.user: + ui.label(f"({n.user})").classes("text-caption text-grey-7") + ui.button(icon="close", on_click=lambda nid=n.id: _clear_notif(nid)).props("flat dense size=xs") + else: + ui.label("No notifications.").classes("text-caption text-grey-7 q-pa-sm") + + if notifs: + ui.button("Clear All", on_click=lambda: _clear_all_notifs()).props("flat color=negative").classes("q-mt-sm") + + +def _clear_notif(nid: str): + notifications.clear(nid) + ui.navigate.to("/admin/diagnostics") + + +def _clear_all_notifs(): + notifications.clear_all() + ui.navigate.to("/admin/diagnostics") diff --git a/wiregui/pages/admin/rules.py b/wiregui/pages/admin/rules.py new file mode 100644 index 0000000..982b71d --- /dev/null +++ b/wiregui/pages/admin/rules.py @@ -0,0 +1,228 @@ +"""Admin firewall rules management page.""" + +from uuid import UUID + +from loguru import logger +from nicegui import app, ui +from sqlmodel import select + +from wiregui.db import async_session +from wiregui.models.rule import Rule +from wiregui.models.user import User +from wiregui.pages.layout import layout +from wiregui.services.events import on_rule_created, on_rule_deleted, on_rule_updated + + +@ui.page("/admin/rules") +async def rules_page(): + if not app.storage.user.get("authenticated") or app.storage.user.get("role") != "admin": + return ui.navigate.to("/login") + + layout() + + # Load users for the dropdown + async with async_session() as session: + users = (await session.execute(select(User).order_by(User.email))).scalars().all() + user_options = {str(u.id): u.email for u in users} + + async def load_rules() -> list[dict]: + async with async_session() as session: + result = await session.execute(select(Rule).order_by(Rule.inserted_at.desc())) + rules = result.scalars().all() + return [ + { + "id": str(r.id), + "action": r.action, + "destination": r.destination, + "port_type": r.port_type or "any", + "port_range": r.port_range or "any", + "user": user_options.get(str(r.user_id), "Global") if r.user_id else "Global", + } + for r in rules + ] + + async def refresh_table(): + table.rows = await load_rules() + table.update() + + async def create_rule(): + dest = dest_input.value.strip() + if not dest: + ui.notify("Destination is required", type="negative") + return + + action_val = action_select.value + port_type_val = port_type_select.value if port_type_select.value != "any" else None + port_range_val = port_range_input.value.strip() or None + user_id_val = user_select.value if user_select.value != "global" else None + + async with async_session() as session: + rule = Rule( + action=action_val, + destination=dest, + port_type=port_type_val, + port_range=port_range_val, + user_id=UUID(user_id_val) if user_id_val else None, + ) + session.add(rule) + await session.commit() + await session.refresh(rule) + + logger.info("Rule created: {} {} -> {}", rule.action, rule.destination, user_id_val or "global") + await on_rule_created(rule) + + create_dialog.close() + _reset_form() + await refresh_table() + + # --- Edit rule --- + edit_rule_id = {"value": None} + + async def open_edit(rule_id: str): + async with async_session() as session: + rule = await session.get(Rule, UUID(rule_id)) + if not rule: + return + edit_rule_id["value"] = rule_id + edit_action.value = rule.action + edit_dest.value = rule.destination + edit_port_type.value = rule.port_type or "any" + edit_port_range.value = rule.port_range or "" + edit_user.value = str(rule.user_id) if rule.user_id else "global" + edit_dialog.open() + + async def save_edit(): + rid = edit_rule_id["value"] + if not rid: + return + + async with async_session() as session: + rule = await session.get(Rule, UUID(rid)) + if not rule: + return + + rule.action = edit_action.value + rule.destination = edit_dest.value.strip() + rule.port_type = edit_port_type.value if edit_port_type.value != "any" else None + rule.port_range = edit_port_range.value.strip() or None + rule.user_id = UUID(edit_user.value) if edit_user.value != "global" else None + + session.add(rule) + await session.commit() + await session.refresh(rule) + await on_rule_updated(rule) + + logger.info("Rule updated: {} {}", edit_action.value, edit_dest.value) + ui.notify("Rule updated") + edit_dialog.close() + await refresh_table() + + async def delete_rule(rule_id: str): + async with async_session() as session: + rule = await session.get(Rule, UUID(rule_id)) + if rule: + await session.delete(rule) + await session.commit() + logger.info("Rule deleted: {} {}", rule.action, rule.destination) + await on_rule_deleted(rule) + await refresh_table() + + def _reset_form(): + dest_input.value = "" + action_select.value = "accept" + port_type_select.value = "any" + port_range_input.value = "" + user_select.value = "global" + + # Page content + with ui.column().classes("w-full p-4"): + with ui.row().classes("w-full items-center justify-between"): + ui.label("Firewall Rules").classes("text-h5") + ui.button("Add Rule", icon="add", on_click=lambda: create_dialog.open()).props("color=primary") + + columns = [ + {"name": "action", "label": "Action", "field": "action", "align": "left", "sortable": True}, + {"name": "destination", "label": "Destination", "field": "destination", "align": "left", "sortable": True}, + {"name": "port_type", "label": "Protocol", "field": "port_type", "align": "left"}, + {"name": "port_range", "label": "Port(s)", "field": "port_range", "align": "left"}, + {"name": "user", "label": "User", "field": "user", "align": "left"}, + {"name": "actions", "label": "", "field": "id", "align": "center"}, + ] + table = ui.table(columns=columns, rows=[], row_key="id").classes("w-full") + table.add_slot( + "body-cell-actions", + ''' + + + + + ''', + ) + table.on("edit", lambda e: open_edit(e.args)) + table.on("delete", lambda e: delete_rule(e.args)) + + # Create rule dialog + with ui.dialog() as create_dialog: + with ui.card().classes("w-96"): + ui.label("New Firewall Rule").classes("text-h6") + + action_select = ui.select( + ["accept", "drop"], value="accept", label="Action", + ).props("outlined dense").classes("w-full") + + dest_input = ui.input("Destination (CIDR)", placeholder="e.g. 10.0.0.0/8 or 0.0.0.0/0").props( + "outlined dense" + ).classes("w-full") + + port_type_select = ui.select( + ["any", "tcp", "udp"], value="any", label="Protocol", + ).props("outlined dense").classes("w-full") + + port_range_input = ui.input("Port Range", placeholder="e.g. 80 or 80-443 (optional)").props( + "outlined dense" + ).classes("w-full") + + user_options_list = [{"label": "Global (all users)", "value": "global"}] + [ + {"label": email, "value": uid} for uid, email in user_options.items() + ] + user_select = ui.select( + {item["value"]: item["label"] for item in user_options_list}, + value="global", + label="Applies to", + ).props("outlined dense").classes("w-full") + + with ui.row().classes("w-full justify-end q-mt-sm"): + ui.button("Cancel", on_click=create_dialog.close).props("flat") + ui.button("Create", on_click=create_rule).props("color=primary") + + # Edit rule dialog + user_options_map = {"global": "Global (all users)"} + user_options_map.update(user_options) + + with ui.dialog() as edit_dialog: + with ui.card().classes("w-96"): + ui.label("Edit Firewall Rule").classes("text-h6") + + edit_action = ui.select( + ["accept", "drop"], value="accept", label="Action", + ).props("outlined dense").classes("w-full") + + edit_dest = ui.input("Destination (CIDR)").props("outlined dense").classes("w-full") + + edit_port_type = ui.select( + ["any", "tcp", "udp"], value="any", label="Protocol", + ).props("outlined dense").classes("w-full") + + edit_port_range = ui.input("Port Range").props("outlined dense").classes("w-full") + + edit_user = ui.select( + user_options_map, value="global", label="Applies to", + ).props("outlined dense").classes("w-full") + + with ui.row().classes("w-full justify-end q-mt-sm"): + ui.button("Cancel", on_click=edit_dialog.close).props("flat") + ui.button("Save", on_click=save_edit).props("color=primary") + + await refresh_table() diff --git a/wiregui/pages/admin/settings.py b/wiregui/pages/admin/settings.py new file mode 100644 index 0000000..245abb2 --- /dev/null +++ b/wiregui/pages/admin/settings.py @@ -0,0 +1,367 @@ +"""Admin settings pages — tabbed interface for configuration management.""" + +from uuid import UUID + +from loguru import logger +from nicegui import app, ui +from sqlmodel import select + +from wiregui.db import async_session +from wiregui.models.configuration import Configuration +from wiregui.pages.layout import layout +from wiregui.utils.time import utcnow + + +def _guard(): + if not app.storage.user.get("authenticated") or app.storage.user.get("role") != "admin": + ui.navigate.to("/login") + return False + return True + + +async def _get_or_create_config() -> Configuration: + async with async_session() as session: + result = await session.execute(select(Configuration).limit(1)) + config = result.scalar_one_or_none() + if not config: + config = Configuration() + session.add(config) + await session.commit() + await session.refresh(config) + return config + + +VPN_SESSION_OPTIONS = { + 0: "Never (unlimited)", + 3600: "Every Hour", + 86400: "Every Day", + 604800: "Every Week", + 2592000: "Every 30 Days", + 7776000: "Every 90 Days", +} + + +@ui.page("/admin/settings") +async def settings_page(): + if not _guard(): + return + + layout() + config = await _get_or_create_config() + + # --- Client Defaults tab --- + async def save_defaults(): + async with async_session() as session: + c = await session.get(Configuration, config.id) + c.default_client_endpoint = defaults_endpoint.value.strip() or None + c.default_client_dns = [s.strip() for s in defaults_dns.value.split(",") if s.strip()] + c.default_client_mtu = int(defaults_mtu.value) if defaults_mtu.value else 1280 + c.default_client_persistent_keepalive = int(defaults_keepalive.value) if defaults_keepalive.value else 25 + c.default_client_allowed_ips = [s.strip() for s in defaults_allowed_ips.value.split(",") if s.strip()] + c.updated_at = utcnow() + session.add(c) + await session.commit() + logger.info("Client defaults updated") + ui.notify("Client defaults saved", type="positive") + + # --- Security tab --- + async def save_security(): + async with async_session() as session: + c = await session.get(Configuration, config.id) + c.vpn_session_duration = security_vpn_duration.value + c.local_auth_enabled = security_local_auth.value + c.allow_unprivileged_device_management = security_unpriv_mgmt.value + c.allow_unprivileged_device_configuration = security_unpriv_config.value + c.disable_vpn_on_oidc_error = security_disable_vpn_oidc.value + c.updated_at = utcnow() + session.add(c) + await session.commit() + logger.info("Security settings updated") + ui.notify("Security settings saved", type="positive") + + # --- OIDC provider management --- + async def save_oidc_provider(): + provider = { + "id": oidc_id.value.strip(), + "label": oidc_label.value.strip(), + "scope": oidc_scope.value.strip(), + "response_type": "code", + "client_id": oidc_client_id.value.strip(), + "client_secret": oidc_client_secret.value.strip(), + "discovery_document_uri": oidc_discovery.value.strip(), + "auto_create_users": oidc_auto_create.value, + } + if not all([provider["id"], provider["label"], provider["client_id"], + provider["client_secret"], provider["discovery_document_uri"]]): + ui.notify("All required fields must be filled", type="negative") + return + + async with async_session() as session: + c = await session.get(Configuration, config.id) + providers = list(c.openid_connect_providers or []) + # Replace existing or add new + providers = [p for p in providers if p.get("id") != provider["id"]] + providers.append(provider) + c.openid_connect_providers = providers + c.updated_at = utcnow() + session.add(c) + await session.commit() + + logger.info("OIDC provider saved: {}", provider["id"]) + ui.notify(f"OIDC provider '{provider['label']}' saved", type="positive") + oidc_dialog.close() + await refresh_oidc_table() + + async def delete_oidc_provider(provider_id: str): + async with async_session() as session: + c = await session.get(Configuration, config.id) + c.openid_connect_providers = [p for p in (c.openid_connect_providers or []) if p.get("id") != provider_id] + c.updated_at = utcnow() + session.add(c) + await session.commit() + logger.info("OIDC provider deleted: {}", provider_id) + ui.notify("OIDC provider deleted") + await refresh_oidc_table() + + async def refresh_oidc_table(): + async with async_session() as session: + c = await session.get(Configuration, config.id) + providers = c.openid_connect_providers or [] + oidc_table.rows = [ + { + "id": p.get("id", ""), + "label": p.get("label", ""), + "client_id": p.get("client_id", ""), + "discovery": p.get("discovery_document_uri", "")[:50] + "...", + "auto_create": "Yes" if p.get("auto_create_users") else "No", + } + for p in providers + ] + oidc_table.update() + + # --- Page content --- + with ui.column().classes("w-full p-4"): + ui.label("Settings").classes("text-h5 q-mb-md") + + with ui.tabs().classes("w-full") as tabs: + defaults_tab = ui.tab("Client Defaults") + security_tab = ui.tab("Security") + auth_tab = ui.tab("Authentication") + + with ui.tab_panels(tabs, value=defaults_tab).classes("w-full"): + + # === Client Defaults === + with ui.tab_panel(defaults_tab): + with ui.card().classes("w-full"): + ui.label("Default Client Configuration").classes("text-subtitle1 text-bold") + ui.label("These defaults apply to new devices unless overridden per-device.").classes("text-caption text-grey-7") + ui.separator() + + defaults_endpoint = ui.input( + "Endpoint", value=config.default_client_endpoint or "", + placeholder="vpn.example.com", + ).props("outlined dense").classes("w-full") + ui.label("IPv4/IPv6 address or FQDN clients connect to").classes("text-caption text-grey-7") + + defaults_dns = ui.input( + "DNS Servers", value=", ".join(config.default_client_dns), + placeholder="1.1.1.1, 1.0.0.1", + ).props("outlined dense").classes("w-full q-mt-sm") + ui.label("Comma-separated. Leave blank to omit.").classes("text-caption text-grey-7") + + defaults_allowed_ips = ui.input( + "Allowed IPs", value=", ".join(config.default_client_allowed_ips), + placeholder="0.0.0.0/0, ::/0", + ).props("outlined dense").classes("w-full q-mt-sm") + ui.label("CIDR ranges for split or full tunnel.").classes("text-caption text-grey-7") + + with ui.row().classes("w-full gap-4 q-mt-sm"): + defaults_mtu = ui.input( + "MTU", value=str(config.default_client_mtu), + placeholder="1280", + ).props("outlined dense").classes("w-48") + + defaults_keepalive = ui.input( + "Persistent Keepalive", value=str(config.default_client_persistent_keepalive), + placeholder="25", + ).props("outlined dense").classes("w-48") + + ui.button("Save Defaults", on_click=save_defaults).props("color=primary").classes("q-mt-md") + + # === Security === + with ui.tab_panel(security_tab): + with ui.card().classes("w-full"): + ui.label("Authentication & Access").classes("text-subtitle1 text-bold") + ui.separator() + + security_vpn_duration = ui.select( + VPN_SESSION_OPTIONS, + value=config.vpn_session_duration, + label="VPN Session Duration", + ).props("outlined dense").classes("w-full") + ui.label("How often users must re-authenticate to maintain VPN access.").classes("text-caption text-grey-7") + + ui.separator().classes("q-my-md") + + security_local_auth = ui.switch("Local Authentication (email/password)", value=config.local_auth_enabled) + security_unpriv_mgmt = ui.switch("Allow Unprivileged Device Management", value=config.allow_unprivileged_device_management) + security_unpriv_config = ui.switch("Allow Unprivileged Device Configuration", value=config.allow_unprivileged_device_configuration) + + ui.separator().classes("q-my-md") + ui.label("SSO Behavior").classes("text-subtitle2") + security_disable_vpn_oidc = ui.switch("Auto-disable VPN on OIDC refresh error", value=config.disable_vpn_on_oidc_error) + + ui.button("Save Security Settings", on_click=save_security).props("color=primary").classes("q-mt-md") + + # === Authentication (OIDC/SAML) === + with ui.tab_panel(auth_tab): + with ui.card().classes("w-full"): + ui.label("OpenID Connect Providers").classes("text-subtitle1 text-bold") + ui.separator() + + oidc_columns = [ + {"name": "id", "label": "Config ID", "field": "id", "align": "left"}, + {"name": "label", "label": "Label", "field": "label", "align": "left"}, + {"name": "client_id", "label": "Client ID", "field": "client_id", "align": "left"}, + {"name": "discovery", "label": "Discovery URI", "field": "discovery", "align": "left"}, + {"name": "auto_create", "label": "Auto-create", "field": "auto_create", "align": "center"}, + {"name": "actions", "label": "", "field": "id", "align": "center"}, + ] + oidc_table = ui.table(columns=oidc_columns, rows=[], row_key="id").classes("w-full") + oidc_table.add_slot( + "body-cell-actions", + ''' + + + + ''', + ) + oidc_table.on("delete", lambda e: delete_oidc_provider(e.args)) + + ui.button("Add OIDC Provider", icon="add", on_click=lambda: oidc_dialog.open()).props("outline").classes("q-mt-sm") + + with ui.card().classes("w-full q-mt-md"): + ui.label("SAML Identity Providers").classes("text-subtitle1 text-bold") + ui.separator() + + saml_columns = [ + {"name": "id", "label": "Config ID", "field": "id", "align": "left"}, + {"name": "label", "label": "Label", "field": "label", "align": "left"}, + {"name": "metadata", "label": "Metadata", "field": "metadata", "align": "left"}, + {"name": "auto_create", "label": "Auto-create", "field": "auto_create", "align": "center"}, + {"name": "actions", "label": "", "field": "id", "align": "center"}, + ] + saml_table = ui.table(columns=saml_columns, rows=[], row_key="id").classes("w-full") + saml_table.add_slot( + "body-cell-actions", + ''' + + + + ''', + ) + saml_table.on("delete", lambda e: delete_saml_provider(e.args)) + + ui.button("Add SAML Provider", icon="add", on_click=lambda: saml_dialog.open()).props("outline").classes("q-mt-sm") + + # --- SAML provider management --- + async def save_saml_provider(): + provider = { + "id": saml_id.value.strip(), + "label": saml_label.value.strip(), + "metadata": saml_metadata_input.value.strip(), + "base_url": f"{get_settings().external_url}/auth/saml", + "sign_requests": saml_sign_requests.value, + "sign_metadata": saml_sign_metadata.value, + "signed_assertion_in_resp": saml_signed_assertion.value, + "signed_envelopes_in_resp": saml_signed_envelopes.value, + "auto_create_users": saml_auto_create.value, + } + if not all([provider["id"], provider["label"], provider["metadata"]]): + ui.notify("Config ID, Label, and Metadata are required", type="negative") + return + + async with async_session() as session: + c = await session.get(Configuration, config.id) + providers = list(c.saml_identity_providers or []) + providers = [p for p in providers if p.get("id") != provider["id"]] + providers.append(provider) + c.saml_identity_providers = providers + c.updated_at = utcnow() + session.add(c) + await session.commit() + + logger.info("SAML provider saved: {}", provider["id"]) + ui.notify(f"SAML provider '{provider['label']}' saved", type="positive") + saml_dialog.close() + await refresh_saml_table() + + async def delete_saml_provider(provider_id: str): + async with async_session() as session: + c = await session.get(Configuration, config.id) + c.saml_identity_providers = [p for p in (c.saml_identity_providers or []) if p.get("id") != provider_id] + c.updated_at = utcnow() + session.add(c) + await session.commit() + logger.info("SAML provider deleted: {}", provider_id) + ui.notify("SAML provider deleted") + await refresh_saml_table() + + async def refresh_saml_table(): + async with async_session() as session: + c = await session.get(Configuration, config.id) + providers = c.saml_identity_providers or [] + saml_table.rows = [ + { + "id": p.get("id", ""), + "label": p.get("label", ""), + "metadata": (p.get("metadata", ""))[:40] + "..." if len(p.get("metadata", "")) > 40 else p.get("metadata", ""), + "auto_create": "Yes" if p.get("auto_create_users") else "No", + } + for p in providers + ] + saml_table.update() + + # --- OIDC provider dialog --- + with ui.dialog() as oidc_dialog: + with ui.card().classes("w-[500px]"): + ui.label("OIDC Provider").classes("text-h6") + oidc_id = ui.input("Config ID", placeholder="google").props("outlined dense").classes("w-full") + oidc_label = ui.input("Label", placeholder="Sign in with Google").props("outlined dense").classes("w-full") + oidc_scope = ui.input("Scope", value="openid email profile").props("outlined dense").classes("w-full") + oidc_client_id = ui.input("Client ID").props("outlined dense").classes("w-full") + oidc_client_secret = ui.input("Client Secret", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") + oidc_discovery = ui.input("Discovery Document URI", placeholder="https://accounts.google.com/.well-known/openid-configuration").props("outlined dense").classes("w-full") + oidc_auto_create = ui.switch("Auto-create users", value=False) + + with ui.row().classes("w-full justify-end q-mt-sm"): + ui.button("Cancel", on_click=oidc_dialog.close).props("flat") + ui.button("Save", on_click=save_oidc_provider).props("color=primary") + + # --- SAML provider dialog --- + with ui.dialog() as saml_dialog: + with ui.card().classes("w-[500px]"): + ui.label("SAML Identity Provider").classes("text-h6") + saml_id = ui.input("Config ID", placeholder="okta-saml").props("outlined dense").classes("w-full") + saml_label = ui.input("Label", placeholder="Sign in with Okta").props("outlined dense").classes("w-full") + saml_metadata_input = ui.textarea("IdP Metadata (XML)").props("outlined").classes("w-full").style("min-height: 120px") + ui.label("Paste the full XML metadata from your identity provider.").classes("text-caption text-grey-7") + + ui.separator().classes("q-my-sm") + ui.label("Security Options").classes("text-subtitle2") + saml_sign_requests = ui.switch("Sign authentication requests", value=True) + saml_sign_metadata = ui.switch("Sign SP metadata", value=True) + saml_signed_assertion = ui.switch("Require signed assertions in response", value=True) + saml_signed_envelopes = ui.switch("Require signed envelopes in response", value=True) + + ui.separator().classes("q-my-sm") + saml_auto_create = ui.switch("Auto-create users", value=False) + + with ui.row().classes("w-full justify-end q-mt-sm"): + ui.button("Cancel", on_click=saml_dialog.close).props("flat") + ui.button("Save", on_click=save_saml_provider).props("color=primary") + + await refresh_oidc_table() + await refresh_saml_table() diff --git a/wiregui/pages/admin/users.py b/wiregui/pages/admin/users.py new file mode 100644 index 0000000..aecb663 --- /dev/null +++ b/wiregui/pages/admin/users.py @@ -0,0 +1,236 @@ +"""Admin user management page.""" + +from uuid import UUID + +from loguru import logger +from nicegui import app, ui +from sqlalchemy.orm import selectinload +from sqlmodel import func, select + +from wiregui.auth.passwords import hash_password +from wiregui.db import async_session +from wiregui.models.device import Device +from wiregui.models.rule import Rule +from wiregui.models.user import User +from wiregui.pages.layout import layout +from wiregui.services.events import on_device_deleted +from wiregui.utils.time import utcnow + + +def _guard(): + if not app.storage.user.get("authenticated") or app.storage.user.get("role") != "admin": + ui.navigate.to("/login") + return False + return True + + +@ui.page("/admin/users") +async def users_page(): + if not _guard(): + return + + layout() + + async def load_users() -> list[dict]: + async with async_session() as session: + # Get users with device counts via subquery + device_count_sq = ( + select(Device.user_id, func.count().label("device_count")) + .group_by(Device.user_id) + .subquery() + ) + result = await session.execute( + select(User).order_by(User.email) + ) + users = result.scalars().all() + + # Get device counts separately + counts_result = await session.execute( + select(Device.user_id, func.count().label("cnt")).group_by(Device.user_id) + ) + counts = {str(row[0]): row[1] for row in counts_result.all()} + + return [ + { + "id": str(u.id), + "email": u.email, + "role": u.role, + "devices": counts.get(str(u.id), 0), + "last_signed_in": str(u.last_signed_in_at or "-"), + "method": u.last_signed_in_method or "-", + "status": "Disabled" if u.disabled_at else "Active", + "created": str(u.inserted_at)[:19], + } + for u in users + ] + + async def refresh_table(): + table.rows = await load_users() + table.update() + + # --- Create user --- + async def create_user(): + email = create_email.value.strip() + pwd = create_password.value + role = create_role.value + + if not email or not pwd: + ui.notify("Email and password are required", type="negative") + return + + async with async_session() as session: + existing = (await session.execute(select(User).where(User.email == email))).scalar_one_or_none() + if existing: + ui.notify(f"User {email} already exists", type="negative") + return + + user = User(email=email, password_hash=hash_password(pwd), role=role) + session.add(user) + await session.commit() + + logger.info("Admin created user: {} ({})", email, role) + ui.notify(f"User {email} created") + create_dialog.close() + create_email.value = "" + create_password.value = "" + create_role.value = "unprivileged" + await refresh_table() + + # --- Edit user --- + edit_user_id = {"value": None} + + async def open_edit(user_id: str): + async with async_session() as session: + user = await session.get(User, UUID(user_id)) + if not user: + return + edit_user_id["value"] = user_id + edit_email.value = user.email + edit_role.value = user.role + edit_password.value = "" + edit_disabled.value = user.disabled_at is not None + edit_dialog.open() + + async def save_edit(): + uid = edit_user_id["value"] + if not uid: + return + + async with async_session() as session: + user = await session.get(User, UUID(uid)) + if not user: + return + + user.email = edit_email.value.strip() + user.role = edit_role.value + + if edit_password.value: + user.password_hash = hash_password(edit_password.value) + + if edit_disabled.value and not user.disabled_at: + user.disabled_at = utcnow() + elif not edit_disabled.value and user.disabled_at: + user.disabled_at = None + + session.add(user) + await session.commit() + + logger.info("Admin updated user: {}", edit_email.value) + ui.notify("User updated") + edit_dialog.close() + await refresh_table() + + # --- Delete user --- + async def delete_user(user_id: str): + current_user_id = app.storage.user.get("user_id") + if user_id == current_user_id: + ui.notify("Cannot delete your own account", type="negative") + return + + async with async_session() as session: + user = await session.get(User, UUID(user_id)) + if not user: + return + + # Delete user's devices (and fire WG events) + devices_result = await session.execute( + select(Device).where(Device.user_id == user.id) + ) + for device in devices_result.scalars().all(): + await session.delete(device) + await on_device_deleted(device) + + # Delete user's rules + await session.execute( + select(Rule).where(Rule.user_id == user.id) + ) + rules_result = await session.execute(select(Rule).where(Rule.user_id == user.id)) + for rule in rules_result.scalars().all(): + await session.delete(rule) + + await session.delete(user) + await session.commit() + + logger.info("Admin deleted user: {}", user.email) + ui.notify(f"User {user.email} deleted") + await refresh_table() + + def on_row_click(e): + open_edit(e.args["id"]) + + # --- Page content --- + with ui.column().classes("w-full p-4"): + with ui.row().classes("w-full items-center justify-between"): + ui.label("Users").classes("text-h5") + ui.button("Add User", icon="person_add", on_click=lambda: create_dialog.open()).props("color=primary") + + columns = [ + {"name": "email", "label": "Email", "field": "email", "align": "left", "sortable": True}, + {"name": "role", "label": "Role", "field": "role", "align": "left", "sortable": True}, + {"name": "devices", "label": "Devices", "field": "devices", "align": "center"}, + {"name": "status", "label": "Status", "field": "status", "align": "left"}, + {"name": "last_signed_in", "label": "Last Sign-in", "field": "last_signed_in", "align": "left"}, + {"name": "method", "label": "Method", "field": "method", "align": "left"}, + {"name": "created", "label": "Created", "field": "created", "align": "left"}, + {"name": "actions", "label": "", "field": "id", "align": "center"}, + ] + table = ui.table(columns=columns, rows=[], row_key="id").classes("w-full") + table.on("rowClick", on_row_click) + table.add_slot( + "body-cell-actions", + ''' + + + + + ''', + ) + table.on("edit", lambda e: open_edit(e.args)) + table.on("delete", lambda e: delete_user(e.args)) + + # --- Create dialog --- + with ui.dialog() as create_dialog: + with ui.card().classes("w-96"): + ui.label("New User").classes("text-h6") + create_email = ui.input("Email").props("outlined dense").classes("w-full") + create_password = ui.input("Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") + create_role = ui.select(["unprivileged", "admin"], value="unprivileged", label="Role").props("outlined dense").classes("w-full") + with ui.row().classes("w-full justify-end q-mt-sm"): + ui.button("Cancel", on_click=create_dialog.close).props("flat") + ui.button("Create", on_click=create_user).props("color=primary") + + # --- Edit dialog --- + with ui.dialog() as edit_dialog: + with ui.card().classes("w-96"): + ui.label("Edit User").classes("text-h6") + edit_email = ui.input("Email").props("outlined dense").classes("w-full") + edit_role = ui.select(["unprivileged", "admin"], value="unprivileged", label="Role").props("outlined dense").classes("w-full") + edit_password = ui.input("New Password (leave blank to keep)", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") + edit_disabled = ui.switch("Disabled") + with ui.row().classes("w-full justify-end q-mt-sm"): + ui.button("Cancel", on_click=edit_dialog.close).props("flat") + ui.button("Save", on_click=save_edit).props("color=primary") + + await refresh_table() diff --git a/wiregui/pages/auth_magic.py b/wiregui/pages/auth_magic.py new file mode 100644 index 0000000..229a79f --- /dev/null +++ b/wiregui/pages/auth_magic.py @@ -0,0 +1,91 @@ +"""Magic link authentication — request and verify signed JWT email links.""" + +from datetime import timedelta +from uuid import UUID + +from loguru import logger +from nicegui import app, ui +from sqlmodel import select + +from wiregui.auth.jwt import create_access_token, decode_access_token +from wiregui.config import get_settings +from wiregui.db import async_session +from wiregui.models.user import User +from wiregui.services.email import send_magic_link +from wiregui.utils.time import utcnow + + +@ui.page("/auth/magic-link") +async def magic_link_request_page(): + """Page to request a magic link email.""" + if app.storage.user.get("authenticated"): + return ui.navigate.to("/") + + async def request_link(): + email_val = email_input.value.strip() + if not email_val: + ui.notify("Enter your email", type="negative") + return + + # Always show success to avoid user enumeration + ui.notify("If an account exists, a sign-in link has been sent.", type="positive") + + async with async_session() as session: + result = await session.execute(select(User).where(User.email == email_val)) + user = result.scalar_one_or_none() + + if user and user.disabled_at is None: + settings = get_settings() + token = create_access_token( + user_id=str(user.id), + role=user.role, + expires_delta=timedelta(minutes=15), + ) + link = f"{settings.external_url}/auth/magic/{user.id}/{token}" + await send_magic_link(email_val, link) + logger.info("Magic link sent to {}", email_val) + + with ui.column().classes("absolute-center items-center"): + ui.label("WireGUI").classes("text-h4 text-bold") + ui.label("Sign in with magic link").classes("text-subtitle1 q-mb-md") + + with ui.card().classes("w-80"): + email_input = ui.input("Email").props("outlined dense").classes("w-full") + ui.button("Send Magic Link", on_click=request_link).classes("w-full q-mt-sm") + email_input.on("keydown.enter", request_link) + + ui.button("Back to login", on_click=lambda: ui.navigate.to("/login")).props("flat").classes("q-mt-md") + + +@ui.page("/auth/magic/{user_id}/{token}") +async def magic_link_verify_page(user_id: str, token: str): + """Verify a magic link token and sign the user in.""" + payload = decode_access_token(token) + if not payload or payload.get("sub") != user_id: + with ui.column().classes("absolute-center items-center"): + ui.label("Invalid or expired link").classes("text-h5 text-negative") + ui.button("Back to login", on_click=lambda: ui.navigate.to("/login")).props("flat") + return + + async with async_session() as session: + user = await session.get(User, UUID(user_id)) + if not user or user.disabled_at is not None: + with ui.column().classes("absolute-center items-center"): + ui.label("Account not found or disabled").classes("text-h5 text-negative") + ui.button("Back to login", on_click=lambda: ui.navigate.to("/login")).props("flat") + return + + user.last_signed_in_at = utcnow() + user.last_signed_in_method = "magic_link" + session.add(user) + await session.commit() + + logger.info("Magic link login: {}", user.email) + + app.storage.user.update( + authenticated=True, + user_id=str(user.id), + email=user.email, + role=user.role, + ) + ui.navigate.to("/") diff --git a/wiregui/pages/auth_oidc.py b/wiregui/pages/auth_oidc.py new file mode 100644 index 0000000..8efaf11 --- /dev/null +++ b/wiregui/pages/auth_oidc.py @@ -0,0 +1,120 @@ +"""OIDC authentication routes — redirect to provider and handle callback.""" + +from loguru import logger +from nicegui import app + +from fastapi import Request +from fastapi.responses import RedirectResponse + +from wiregui.auth.oidc import get_client, get_provider_config +from wiregui.config import get_settings +from wiregui.db import async_session +from wiregui.models.oidc_connection import OIDCConnection +from wiregui.models.user import User +from wiregui.utils.time import utcnow + +from sqlmodel import select + + +@app.get("/auth/oidc/{provider_id}") +async def oidc_redirect(provider_id: str, request: Request): + """Redirect user to the OIDC provider's authorization endpoint.""" + try: + client = get_client(provider_id) + except ValueError: + return RedirectResponse(url="/login") + + settings = get_settings() + redirect_uri = f"{settings.external_url}/auth/oidc/{provider_id}/callback" + return await client.authorize_redirect(request, redirect_uri) + + +@app.get("/auth/oidc/{provider_id}/callback") +async def oidc_callback(provider_id: str, request: Request): + """Handle the OIDC provider callback — exchange code for tokens and create session.""" + try: + client = get_client(provider_id) + except ValueError: + return RedirectResponse(url="/login") + + try: + token = await client.authorize_access_token(request) + except Exception as e: + logger.error("OIDC token exchange failed for {}: {}", provider_id, e) + return RedirectResponse(url="/login") + + userinfo = token.get("userinfo") + if not userinfo: + try: + userinfo = await client.userinfo() + except Exception as e: + logger.error("OIDC userinfo failed for {}: {}", provider_id, e) + return RedirectResponse(url="/login") + + email = userinfo.get("email") + if not email: + logger.error("OIDC provider {} did not return email", provider_id) + return RedirectResponse(url="/login") + + provider_config = await get_provider_config(provider_id) + auto_create = provider_config.get("auto_create_users", False) if provider_config else False + + async with async_session() as session: + # Find or create user + result = await session.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if user is None: + if not auto_create: + logger.warning("OIDC: user {} not found and auto-create disabled for {}", email, provider_id) + return RedirectResponse(url="/login") + + user = User(email=email, role="unprivileged") + session.add(user) + await session.flush() + logger.info("OIDC: auto-created user {} via {}", email, provider_id) + + if user.disabled_at is not None: + logger.warning("OIDC: disabled user {} attempted login via {}", email, provider_id) + return RedirectResponse(url="/login") + + # Update sign-in tracking + user.last_signed_in_at = utcnow() + user.last_signed_in_method = f"oidc:{provider_id}" + session.add(user) + + # Store/update OIDC connection with refresh token + refresh_token = token.get("refresh_token") + existing_conn = (await session.execute( + select(OIDCConnection).where( + OIDCConnection.user_id == user.id, + OIDCConnection.provider == provider_id, + ) + )).scalar_one_or_none() + + if existing_conn: + existing_conn.refresh_token = refresh_token + existing_conn.refreshed_at = utcnow() + existing_conn.refresh_response = dict(token) + session.add(existing_conn) + else: + conn = OIDCConnection( + provider=provider_id, + refresh_token=refresh_token, + refresh_response=dict(token), + refreshed_at=utcnow(), + user_id=user.id, + ) + session.add(conn) + + await session.commit() + + logger.info("OIDC login: {} via {}", email, provider_id) + + # Set NiceGUI session — store in Starlette session since we're in a plain route + request.session["authenticated"] = True + request.session["user_id"] = str(user.id) + request.session["email"] = user.email + request.session["role"] = user.role + + return RedirectResponse(url="/") diff --git a/wiregui/pages/auth_saml.py b/wiregui/pages/auth_saml.py new file mode 100644 index 0000000..d8ed476 --- /dev/null +++ b/wiregui/pages/auth_saml.py @@ -0,0 +1,129 @@ +"""SAML authentication routes — SP-initiated SSO redirect and ACS callback.""" + +from urllib.parse import urlparse + +from fastapi import Request +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from loguru import logger +from nicegui import app +from sqlmodel import select + +from wiregui.auth.saml import create_saml_auth, get_login_url, get_metadata, process_response +from wiregui.config import get_settings +from wiregui.db import async_session +from wiregui.models.configuration import Configuration +from wiregui.models.user import User +from wiregui.utils.time import utcnow + + +async def _get_saml_provider(provider_id: str) -> dict | None: + async with async_session() as session: + config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none() + if not config: + return None + for p in config.saml_identity_providers or []: + if p.get("id") == provider_id: + return p + return None + + +def _request_data_from_fastapi(request: Request) -> dict: + settings = get_settings() + parsed = urlparse(settings.external_url) + return { + "http_host": parsed.hostname, + "script_name": "", + "server_port": parsed.port or (443 if parsed.scheme == "https" else 80), + "get_data": dict(request.query_params), + "post_data": {}, + "https": "on" if parsed.scheme == "https" else "off", + } + + +@app.get("/auth/saml/{provider_id}") +async def saml_redirect(provider_id: str, request: Request): + """Redirect user to the SAML IdP.""" + provider = await _get_saml_provider(provider_id) + if not provider: + return RedirectResponse(url="/login") + + try: + req_data = _request_data_from_fastapi(request) + auth = create_saml_auth(provider, req_data) + login_url = get_login_url(auth) + return RedirectResponse(url=login_url) + except Exception as e: + logger.error("SAML redirect failed for {}: {}", provider_id, e) + return RedirectResponse(url="/login") + + +@app.post("/auth/saml/{provider_id}/callback") +async def saml_callback(provider_id: str, request: Request): + """Handle the SAML ACS callback (POST with SAMLResponse).""" + provider = await _get_saml_provider(provider_id) + if not provider: + return RedirectResponse(url="/login") + + try: + form_data = await request.form() + req_data = _request_data_from_fastapi(request) + req_data["post_data"] = dict(form_data) + + auth = create_saml_auth(provider, req_data) + user_data = process_response(auth) + + if not user_data or not user_data.get("email"): + logger.warning("SAML callback: no valid user data from {}", provider_id) + return RedirectResponse(url="/login") + + email = user_data["email"] + auto_create = provider.get("auto_create_users", False) + + async with async_session() as session: + result = await session.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if user is None: + if not auto_create: + logger.warning("SAML: user {} not found, auto-create disabled for {}", email, provider_id) + return RedirectResponse(url="/login") + user = User(email=email, role="unprivileged") + session.add(user) + await session.flush() + logger.info("SAML: auto-created user {} via {}", email, provider_id) + + if user.disabled_at is not None: + logger.warning("SAML: disabled user {} attempted login via {}", email, provider_id) + return RedirectResponse(url="/login") + + user.last_signed_in_at = utcnow() + user.last_signed_in_method = f"saml:{provider_id}" + session.add(user) + await session.commit() + + request.session["authenticated"] = True + request.session["user_id"] = str(user.id) + request.session["email"] = user.email + request.session["role"] = user.role + + logger.info("SAML login: {} via {}", email, provider_id) + return RedirectResponse(url="/", status_code=303) + + except Exception as e: + logger.error("SAML callback failed for {}: {}", provider_id, e) + return RedirectResponse(url="/login") + + +@app.get("/auth/saml/{provider_id}/metadata") +async def saml_metadata(provider_id: str): + """Return SP metadata XML for the SAML provider.""" + provider = await _get_saml_provider(provider_id) + if not provider: + return Response(status_code=404) + + try: + metadata_xml = get_metadata(provider) + return Response(content=metadata_xml, media_type="application/xml") + except Exception as e: + logger.error("SAML metadata generation failed for {}: {}", provider_id, e) + return Response(status_code=500) diff --git a/wiregui/pages/devices.py b/wiregui/pages/devices.py new file mode 100644 index 0000000..a9147fd --- /dev/null +++ b/wiregui/pages/devices.py @@ -0,0 +1,463 @@ +"""User-facing device management pages.""" + +import io +from uuid import UUID + +import qrcode +import qrcode.image.svg +from loguru import logger +from nicegui import app, ui +from sqlmodel import select + +from wiregui.config import get_settings +from wiregui.db import async_session +from wiregui.models.device import Device +from wiregui.pages.layout import layout +from wiregui.services.events import on_device_created, on_device_deleted, on_device_updated +from wiregui.utils.crypto import generate_keypair, generate_preshared_key +from wiregui.utils.network import allocate_ipv4, allocate_ipv6 +from wiregui.utils.server_key import get_server_public_key +from wiregui.utils.wg_conf import build_client_config + + +def _format_bytes(b: int | None) -> str: + if b is None: + return "-" + for unit in ("B", "KB", "MB", "GB", "TB"): + if b < 1024: + return f"{b:.1f} {unit}" + b /= 1024 + return f"{b:.1f} PB" + + +@ui.page("/devices") +async def devices_page(): + if not app.storage.user.get("authenticated"): + return ui.navigate.to("/login") + + layout() + user_id = UUID(app.storage.user["user_id"]) + + async def load_devices() -> list[Device]: + async with async_session() as session: + result = await session.execute( + select(Device).where(Device.user_id == user_id).order_by(Device.inserted_at.desc()) + ) + return list(result.scalars().all()) + + async def refresh_table(): + devices = await load_devices() + table.rows = [ + { + "id": str(d.id), + "name": d.name, + "description": d.description or "", + "ipv4": d.ipv4 or "-", + "ipv6": d.ipv6 or "-", + "public_key": d.public_key[:16] + "...", + "rx": _format_bytes(d.rx_bytes), + "tx": _format_bytes(d.tx_bytes), + "handshake": str(d.latest_handshake)[:19] if d.latest_handshake else "-", + } + for d in devices + ] + table.update() + + # --- Create device --- + async def create_device(): + name = create_name.value.strip() + if not name: + ui.notify("Device name is required", type="negative") + return + + try: + settings = get_settings() + private_key, public_key = generate_keypair() + psk = generate_preshared_key() + + async with async_session() as session: + ipv4 = await allocate_ipv4(session, settings.wg_ipv4_network) + ipv6 = await allocate_ipv6(session, settings.wg_ipv6_network) + + device = Device( + name=name, + description=create_desc.value.strip() or None, + public_key=public_key, + preshared_key=psk, + ipv4=ipv4, + ipv6=ipv6, + user_id=user_id, + use_default_allowed_ips=create_use_default_ips.value, + use_default_dns=create_use_default_dns.value, + use_default_endpoint=create_use_default_endpoint.value, + use_default_mtu=create_use_default_mtu.value, + use_default_persistent_keepalive=create_use_default_keepalive.value, + endpoint=(create_endpoint.value.strip() or None + if not create_use_default_endpoint.value else None), + dns=([s.strip() for s in create_dns.value.split(",") if s.strip()] + if not create_use_default_dns.value and create_dns.value else []), + mtu=(int(create_mtu.value) + if not create_use_default_mtu.value and create_mtu.value else None), + persistent_keepalive=(int(create_keepalive.value) + if not create_use_default_keepalive.value and create_keepalive.value else None), + allowed_ips=([s.strip() for s in create_allowed_ips.value.split(",") if s.strip()] + if not create_use_default_ips.value and create_allowed_ips.value else []), + ) + session.add(device) + await session.commit() + await session.refresh(device) + + logger.info("Device created: {} ({})", device.name, device.ipv4) + await on_device_created(device) + + server_pubkey = await get_server_public_key() + config_text = build_client_config(device, private_key, server_pubkey) + _show_config_dialog(device.name, config_text) + + create_dialog.close() + _reset_create_form() + await refresh_table() + + except Exception as e: + logger.error("Failed to create device: {}", e) + ui.notify(f"Error: {e}", type="negative") + + def _reset_create_form(): + create_name.value = "" + create_desc.value = "" + create_use_default_ips.value = True + create_use_default_dns.value = True + create_use_default_endpoint.value = True + create_use_default_mtu.value = True + create_use_default_keepalive.value = True + create_endpoint.value = "" + create_dns.value = "" + create_mtu.value = "" + create_keepalive.value = "" + create_allowed_ips.value = "" + + # --- Delete device --- + async def delete_device(device_id: str): + async with async_session() as session: + device = await session.get(Device, UUID(device_id)) + if device and device.user_id == user_id: + await session.delete(device) + await session.commit() + logger.info("Device deleted: {}", device.name) + await on_device_deleted(device) + ui.notify(f"Deleted {device.name}") + await refresh_table() + + def on_row_click(e): + ui.navigate.to(f"/devices/{e.args['id']}") + + # --- Page content --- + with ui.column().classes("w-full p-4"): + with ui.row().classes("w-full items-center justify-between"): + ui.label("My Devices").classes("text-h5") + ui.button("Add Device", icon="add", on_click=lambda: create_dialog.open()).props("color=primary") + + columns = [ + {"name": "name", "label": "Name", "field": "name", "align": "left", "sortable": True}, + {"name": "ipv4", "label": "IPv4", "field": "ipv4", "align": "left"}, + {"name": "ipv6", "label": "IPv6", "field": "ipv6", "align": "left"}, + {"name": "public_key", "label": "Public Key", "field": "public_key", "align": "left"}, + {"name": "rx", "label": "RX", "field": "rx", "align": "right"}, + {"name": "tx", "label": "TX", "field": "tx", "align": "right"}, + {"name": "handshake", "label": "Last Handshake", "field": "handshake", "align": "left"}, + {"name": "actions", "label": "", "field": "id", "align": "center"}, + ] + table = ui.table(columns=columns, rows=[], row_key="id").classes("w-full") + table.on("rowClick", on_row_click) + table.add_slot( + "body-cell-actions", + ''' + + + + ''', + ) + table.on("delete", lambda e: delete_device(e.args)) + + # --- Create device dialog (full form) --- + with ui.dialog() as create_dialog: + with ui.card().classes("w-[600px]"): + ui.label("New Device").classes("text-h6") + + create_name = ui.input("Device Name").props("outlined dense").classes("w-full") + create_desc = ui.input("Description (optional)").props("outlined dense").classes("w-full") + + ui.separator().classes("q-my-sm") + ui.label("Configuration Overrides").classes("text-subtitle2") + ui.label("Toggle off to set custom values instead of server defaults.").classes("text-caption text-grey-7") + + with ui.grid(columns=2).classes("w-full gap-2"): + create_use_default_ips = ui.switch("Use default Allowed IPs", value=True) + create_allowed_ips = ui.input("Allowed IPs", placeholder="0.0.0.0/0, ::/0").props( + "outlined dense" + ).classes("w-full").bind_enabled_from(create_use_default_ips, "value", backward=lambda v: not v) + + create_use_default_dns = ui.switch("Use default DNS", value=True) + create_dns = ui.input("DNS Servers", placeholder="1.1.1.1, 1.0.0.1").props( + "outlined dense" + ).classes("w-full").bind_enabled_from(create_use_default_dns, "value", backward=lambda v: not v) + + create_use_default_endpoint = ui.switch("Use default Endpoint", value=True) + create_endpoint = ui.input("Endpoint", placeholder="vpn.example.com").props( + "outlined dense" + ).classes("w-full").bind_enabled_from(create_use_default_endpoint, "value", backward=lambda v: not v) + + create_use_default_mtu = ui.switch("Use default MTU", value=True) + create_mtu = ui.input("MTU", placeholder="1280").props( + "outlined dense" + ).classes("w-full").bind_enabled_from(create_use_default_mtu, "value", backward=lambda v: not v) + + create_use_default_keepalive = ui.switch("Use default Keepalive", value=True) + create_keepalive = ui.input("Persistent Keepalive", placeholder="25").props( + "outlined dense" + ).classes("w-full").bind_enabled_from(create_use_default_keepalive, "value", backward=lambda v: not v) + + with ui.row().classes("w-full justify-end q-mt-md"): + ui.button("Cancel", on_click=create_dialog.close).props("flat") + ui.button("Create", on_click=create_device).props("color=primary") + + await refresh_table() + + # Auto-refresh stats every 30 seconds + ui.timer(30, refresh_table) + + +@ui.page("/devices/{device_id}") +async def device_detail_page(device_id: str): + if not app.storage.user.get("authenticated"): + return ui.navigate.to("/login") + + layout() + user_id = UUID(app.storage.user["user_id"]) + + async with async_session() as sess: + device = await sess.get(Device, UUID(device_id)) + if not device or device.user_id != user_id: + ui.label("Device not found").classes("text-h5 text-negative p-4") + return + + # --- Edit handlers --- + async def save_edit(): + async with async_session() as session: + d = await session.get(Device, UUID(device_id)) + if not d: + return + + d.name = edit_name.value.strip() + d.description = edit_desc.value.strip() or None + d.use_default_allowed_ips = edit_use_default_ips.value + d.use_default_dns = edit_use_default_dns.value + d.use_default_endpoint = edit_use_default_endpoint.value + d.use_default_mtu = edit_use_default_mtu.value + d.use_default_persistent_keepalive = edit_use_default_keepalive.value + + if not d.use_default_endpoint: + d.endpoint = edit_endpoint.value.strip() or None + if not d.use_default_dns: + d.dns = [s.strip() for s in edit_dns.value.split(",") if s.strip()] + if not d.use_default_mtu: + d.mtu = int(edit_mtu.value) if edit_mtu.value else None + if not d.use_default_persistent_keepalive: + d.persistent_keepalive = int(edit_keepalive.value) if edit_keepalive.value else None + if not d.use_default_allowed_ips: + d.allowed_ips = [s.strip() for s in edit_allowed_ips.value.split(",") if s.strip()] + + session.add(d) + await session.commit() + await session.refresh(d) + await on_device_updated(d) + + logger.info("Device updated: {}", edit_name.value) + ui.notify("Device updated", type="positive") + ui.navigate.to(f"/devices/{device_id}") + + async def delete_and_redirect(): + async with async_session() as session: + d = await session.get(Device, UUID(device_id)) + if d: + await session.delete(d) + await session.commit() + logger.info("Device deleted: {}", d.name) + await on_device_deleted(d) + ui.navigate.to("/devices") + + settings = get_settings() + + # --- Page content --- + with ui.column().classes("w-full p-4"): + with ui.row().classes("items-center gap-2"): + ui.button(icon="arrow_back", on_click=lambda: ui.navigate.to("/devices")).props("flat") + ui.label(device.name).classes("text-h5") + if device.description: + ui.label(f"— {device.description}").classes("text-subtitle1 text-grey-7") + + # Device info card + with ui.card().classes("w-full q-mt-md"): + ui.label("Device Details").classes("text-subtitle1 text-bold") + ui.separator() + with ui.grid(columns=2).classes("w-full gap-2 q-pa-sm"): + ui.label("Public Key:").classes("text-bold") + ui.label(device.public_key).classes("font-mono text-sm") + + ui.label("IPv4:").classes("text-bold") + ui.label(device.ipv4 or "-") + + ui.label("IPv6:").classes("text-bold") + ui.label(device.ipv6 or "-") + + ui.label("Created:").classes("text-bold") + ui.label(str(device.inserted_at)[:19]) + + # Traffic stats (live-updating) + with ui.card().classes("w-full q-mt-md"): + ui.label("Traffic Stats").classes("text-subtitle1 text-bold") + ui.label("Auto-refreshes every 30s").classes("text-caption text-grey-7") + ui.separator() + with ui.grid(columns=2).classes("w-full gap-2 q-pa-sm"): + ui.label("RX:").classes("text-bold") + stat_rx = ui.label(_format_bytes(device.rx_bytes)) + + ui.label("TX:").classes("text-bold") + stat_tx = ui.label(_format_bytes(device.tx_bytes)) + + ui.label("Last Handshake:").classes("text-bold") + stat_handshake = ui.label(str(device.latest_handshake)[:19] if device.latest_handshake else "-") + + ui.label("Remote IP:").classes("text-bold") + stat_remote = ui.label(device.remote_ip or "-") + + async def refresh_stats(): + async with async_session() as session: + d = await session.get(Device, UUID(device_id)) + if not d: + return + stat_rx.text = _format_bytes(d.rx_bytes) + stat_tx.text = _format_bytes(d.tx_bytes) + stat_handshake.text = str(d.latest_handshake)[:19] if d.latest_handshake else "-" + stat_remote.text = d.remote_ip or "-" + + ui.timer(30, refresh_stats) + + # Active configuration + with ui.card().classes("w-full q-mt-md"): + ui.label("Active Configuration").classes("text-subtitle1 text-bold") + ui.separator() + with ui.grid(columns=2).classes("w-full gap-2 q-pa-sm"): + _ips = device.allowed_ips if not device.use_default_allowed_ips else settings.wg_allowed_ips + ui.label("Allowed IPs:").classes("text-bold") + ui.label(str(_ips) if isinstance(_ips, str) else ", ".join(_ips) if _ips else "-") + + _dns = device.dns if not device.use_default_dns else settings.wg_dns + ui.label("DNS:").classes("text-bold") + ui.label(str(_dns) if isinstance(_dns, str) else ", ".join(_dns) if _dns else "-") + + _ep = device.endpoint if not device.use_default_endpoint else settings.wg_endpoint_host + ui.label("Endpoint:").classes("text-bold") + ui.label(f"{_ep}:{settings.wg_endpoint_port}" if _ep else "-") + + _mtu = device.mtu if not device.use_default_mtu else settings.wg_mtu + ui.label("MTU:").classes("text-bold") + ui.label(str(_mtu) if _mtu else "-") + + _ka = device.persistent_keepalive if not device.use_default_persistent_keepalive else settings.wg_persistent_keepalive + ui.label("Persistent Keepalive:").classes("text-bold") + ui.label(str(_ka) if _ka else "-") + + # Edit form + with ui.card().classes("w-full q-mt-md"): + ui.label("Edit Device").classes("text-subtitle1 text-bold") + ui.separator() + + edit_name = ui.input("Device Name", value=device.name).props("outlined dense").classes("w-full") + edit_desc = ui.input("Description", value=device.description or "").props("outlined dense").classes("w-full") + + ui.separator().classes("q-my-sm") + ui.label("Configuration Overrides").classes("text-subtitle2") + + with ui.grid(columns=2).classes("w-full gap-2"): + edit_use_default_ips = ui.switch("Use default Allowed IPs", value=device.use_default_allowed_ips) + edit_allowed_ips = ui.input( + "Allowed IPs", value=", ".join(device.allowed_ips) if device.allowed_ips else "", + ).props("outlined dense").classes("w-full").bind_enabled_from( + edit_use_default_ips, "value", backward=lambda v: not v + ) + + edit_use_default_dns = ui.switch("Use default DNS", value=device.use_default_dns) + edit_dns = ui.input( + "DNS Servers", value=", ".join(device.dns) if device.dns else "", + ).props("outlined dense").classes("w-full").bind_enabled_from( + edit_use_default_dns, "value", backward=lambda v: not v + ) + + edit_use_default_endpoint = ui.switch("Use default Endpoint", value=device.use_default_endpoint) + edit_endpoint = ui.input( + "Endpoint", value=device.endpoint or "", + ).props("outlined dense").classes("w-full").bind_enabled_from( + edit_use_default_endpoint, "value", backward=lambda v: not v + ) + + edit_use_default_mtu = ui.switch("Use default MTU", value=device.use_default_mtu) + edit_mtu = ui.input( + "MTU", value=str(device.mtu) if device.mtu else "", + ).props("outlined dense").classes("w-full").bind_enabled_from( + edit_use_default_mtu, "value", backward=lambda v: not v + ) + + edit_use_default_keepalive = ui.switch("Use default Keepalive", value=device.use_default_persistent_keepalive) + edit_keepalive = ui.input( + "Persistent Keepalive", value=str(device.persistent_keepalive) if device.persistent_keepalive else "", + ).props("outlined dense").classes("w-full").bind_enabled_from( + edit_use_default_keepalive, "value", backward=lambda v: not v + ) + + ui.button("Save Changes", on_click=save_edit).props("color=primary").classes("q-mt-md") + + # Danger zone + with ui.card().classes("w-full q-mt-md"): + ui.label("Danger Zone").classes("text-subtitle1 text-bold text-negative") + ui.separator() + ui.button("Delete Device", icon="delete", on_click=lambda: confirm_dialog.open()).props( + "color=negative outline" + ) + + # Confirm delete dialog + with ui.dialog() as confirm_dialog: + with ui.card().classes("w-80"): + ui.label("Delete Device?").classes("text-h6") + ui.label(f"This will permanently remove '{device.name}' and its WireGuard peer.").classes("text-body2") + with ui.row().classes("w-full justify-end q-mt-sm"): + ui.button("Cancel", on_click=confirm_dialog.close).props("flat") + ui.button("Delete", on_click=delete_and_redirect).props("color=negative") + + +def _show_config_dialog(device_name: str, config_text: str): + """Show a dialog with the WireGuard client configuration and QR code.""" + with ui.dialog(value=True) as dialog: + with ui.card().classes("w-96"): + ui.label(f"Config for {device_name}").classes("text-h6") + ui.label("Save this — the private key won't be shown again.").classes("text-caption text-negative") + + ui.textarea(value=config_text).props("readonly outlined").classes( + "w-full font-mono text-xs q-mt-sm" + ).style("min-height: 200px") + + try: + qr = qrcode.make(config_text, image_factory=qrcode.image.svg.SvgPathImage) + buf = io.BytesIO() + qr.save(buf) + ui.html(buf.getvalue().decode()).classes("w-full q-mt-sm") + except Exception: + ui.label("QR code generation failed").classes("text-caption text-grey") + + ui.button( + "Download .conf", + on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf"), + ).props("color=primary outline").classes("w-full q-mt-sm") + + ui.button("Close", on_click=dialog.close).props("flat").classes("w-full") diff --git a/wiregui/pages/home.py b/wiregui/pages/home.py new file mode 100644 index 0000000..7682a93 --- /dev/null +++ b/wiregui/pages/home.py @@ -0,0 +1,9 @@ +from nicegui import app, ui + + +@ui.page("/") +def home_page(): + if not app.storage.user.get("authenticated"): + return ui.navigate.to("/login") + # Redirect to devices as the main landing page + return ui.navigate.to("/devices") diff --git a/wiregui/pages/layout.py b/wiregui/pages/layout.py new file mode 100644 index 0000000..f22de04 --- /dev/null +++ b/wiregui/pages/layout.py @@ -0,0 +1,48 @@ +"""Shared layout — sidebar navigation + header.""" + +from nicegui import app, ui + +from wiregui.services import notifications + + +def layout(title: str = "WireGUI"): + """Render the shared app chrome (header + sidebar). Call at the top of each page.""" + user_email = app.storage.user.get("email", "") + role = app.storage.user.get("role", "") + + def logout(): + app.storage.user.clear() + ui.navigate.to("/login") + + # Header + with ui.header().classes("items-center justify-between"): + with ui.row().classes("items-center"): + ui.button(icon="menu", on_click=lambda: drawer.toggle()).props("flat color=white") + ui.label("WireGUI").classes("text-h6") + with ui.row().classes("items-center"): + if role == "admin": + notif_count = notifications.count() + with ui.button( + icon="notifications", + on_click=lambda: ui.navigate.to("/admin/diagnostics"), + ).props("flat color=white"): + if notif_count > 0: + ui.badge(str(notif_count), color="red").props("floating") + ui.label(f"{user_email}").classes("text-subtitle2") + ui.button("Logout", on_click=logout).props("flat color=white") + + # Sidebar + with ui.left_drawer(value=True, bordered=True).classes("bg-grey-1") as drawer: + ui.label("Navigation").classes("text-subtitle2 q-pa-sm text-grey-7") + ui.separator() + ui.item("Devices", on_click=lambda: ui.navigate.to("/devices")).classes("cursor-pointer") + ui.item("Account", on_click=lambda: ui.navigate.to("/account")).classes("cursor-pointer") + + if role == "admin": + ui.separator() + ui.label("Admin").classes("text-subtitle2 q-pa-sm text-grey-7") + ui.item("Users", on_click=lambda: ui.navigate.to("/admin/users")).classes("cursor-pointer") + ui.item("All Devices", on_click=lambda: ui.navigate.to("/admin/devices")).classes("cursor-pointer") + ui.item("Rules", on_click=lambda: ui.navigate.to("/admin/rules")).classes("cursor-pointer") + ui.item("Settings", on_click=lambda: ui.navigate.to("/admin/settings")).classes("cursor-pointer") + ui.item("Diagnostics", on_click=lambda: ui.navigate.to("/admin/diagnostics")).classes("cursor-pointer") diff --git a/wiregui/pages/login.py b/wiregui/pages/login.py new file mode 100644 index 0000000..6b29056 --- /dev/null +++ b/wiregui/pages/login.py @@ -0,0 +1,82 @@ +"""Login page — email/password, MFA redirect, OIDC provider buttons.""" + +from nicegui import app, ui +from sqlmodel import select + +from wiregui.auth.oidc import load_providers +from wiregui.auth.session import authenticate_user +from wiregui.db import async_session +from wiregui.models.mfa_method import MFAMethod +from wiregui.utils.time import utcnow + + +@ui.page("/login") +async def login_page(): + if app.storage.user.get("authenticated"): + return ui.navigate.to("/") + + # Load OIDC providers for SSO buttons + oidc_providers = await load_providers() + + async def try_login(): + user = await authenticate_user(email.value, password.value) + if user is None: + ui.notify("Invalid email or password", type="negative") + return + + # Check if user has MFA methods + async with async_session() as session: + result = await session.execute( + select(MFAMethod).where(MFAMethod.user_id == user.id) + ) + mfa_methods = result.scalars().all() + + # Update sign-in tracking + user_record = await session.get(type(user), user.id) + user_record.last_signed_in_at = utcnow() + user_record.last_signed_in_method = "local" + session.add(user_record) + await session.commit() + + if mfa_methods: + # Store pending auth and redirect to MFA challenge + app.storage.user["pending_mfa"] = { + "user_id": str(user.id), + "email": user.email, + "role": user.role, + } + ui.navigate.to("/mfa") + else: + # No MFA — complete login directly + app.storage.user.update( + authenticated=True, + user_id=str(user.id), + email=user.email, + role=user.role, + ) + ui.navigate.to("/") + + with ui.column().classes("absolute-center items-center"): + ui.label("WireGUI").classes("text-h4 text-bold") + ui.label("Sign in to your account").classes("text-subtitle1 q-mb-md") + + with ui.card().classes("w-80"): + email = ui.input("Email").props("outlined dense").classes("w-full") + password = ui.input("Password", password=True, password_toggle_button=True).props( + "outlined dense" + ).classes("w-full") + ui.button("Sign in", on_click=try_login).classes("w-full q-mt-sm") + + password.on("keydown.enter", try_login) + + # OIDC provider buttons + if oidc_providers: + ui.separator().classes("q-my-md") + ui.label("Or sign in with").classes("text-caption text-center w-full") + for provider in oidc_providers: + pid = provider.get("id", "") + label = provider.get("label", pid) + ui.button( + label, + on_click=lambda p=pid: ui.navigate.to(f"/auth/oidc/{p}"), + ).props("outline").classes("w-full q-mt-xs") diff --git a/wiregui/pages/mfa_challenge.py b/wiregui/pages/mfa_challenge.py new file mode 100644 index 0000000..b178e3a --- /dev/null +++ b/wiregui/pages/mfa_challenge.py @@ -0,0 +1,93 @@ +"""MFA challenge page — presented after password login when user has MFA enabled.""" + +from uuid import UUID + +from loguru import logger +from nicegui import app, ui +from sqlmodel import select + +from wiregui.auth.mfa import verify_totp_code +from wiregui.db import async_session +from wiregui.models.mfa_method import MFAMethod + + +@ui.page("/mfa") +async def mfa_challenge_page(): + # Must have passed password auth (pending_mfa set by login page) + pending = app.storage.user.get("pending_mfa") + if not pending: + return ui.navigate.to("/login") + + user_id = UUID(pending["user_id"]) + + # Load user's MFA methods + async with async_session() as session: + result = await session.execute( + select(MFAMethod).where(MFAMethod.user_id == user_id) + ) + methods = result.scalars().all() + + totp_methods = [m for m in methods if m.type == "totp"] + + if not totp_methods: + # No MFA methods — shouldn't be here, complete login + _complete_login(pending) + return + + async def verify_code(): + code = code_input.value.strip() + if not code: + ui.notify("Enter your authentication code", type="negative") + return + + for method in totp_methods: + secret = method.payload.get("secret") + if secret and verify_totp_code(secret, code): + # Update last used + async with async_session() as session: + m = await session.get(MFAMethod, method.id) + if m: + from wiregui.utils.time import utcnow + m.last_used_at = utcnow() + session.add(m) + await session.commit() + + logger.info("MFA verified for user {}", pending["email"]) + _complete_login(pending) + return + + ui.notify("Invalid code", type="negative") + + with ui.column().classes("absolute-center items-center"): + ui.label("Two-Factor Authentication").classes("text-h5") + ui.label(f"Enter the code from your authenticator app for {pending['email']}").classes( + "text-subtitle1 q-mb-md" + ) + + with ui.card().classes("w-80"): + code_input = ui.input("Authentication Code").props( + "outlined dense maxlength=6" + ).classes("w-full text-center font-mono text-lg") + ui.button("Verify", on_click=verify_code).classes("w-full q-mt-sm") + + code_input.on("keydown.enter", verify_code) + + ui.button("Cancel", on_click=lambda: _cancel_mfa()).props("flat").classes("q-mt-md") + + +def _complete_login(pending: dict): + """Complete the login by setting full auth state.""" + app.storage.user.update( + authenticated=True, + user_id=pending["user_id"], + email=pending["email"], + role=pending["role"], + ) + # Clear pending state + app.storage.user.pop("pending_mfa", None) + ui.navigate.to("/") + + +def _cancel_mfa(): + app.storage.user.clear() + ui.navigate.to("/login") diff --git a/wiregui/redis.py b/wiregui/redis.py new file mode 100644 index 0000000..031763f --- /dev/null +++ b/wiregui/redis.py @@ -0,0 +1,9 @@ +import redis.asyncio as redis + +from wiregui.config import get_settings + +pool = redis.ConnectionPool.from_url(get_settings().redis_url) + + +def get_redis() -> redis.Redis: + return redis.Redis(connection_pool=pool) diff --git a/wiregui/schemas/__init__.py b/wiregui/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wiregui/schemas/configuration.py b/wiregui/schemas/configuration.py new file mode 100644 index 0000000..2d8a820 --- /dev/null +++ b/wiregui/schemas/configuration.py @@ -0,0 +1,37 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class ConfigurationRead(BaseModel): + id: UUID + allow_unprivileged_device_management: bool + allow_unprivileged_device_configuration: bool + local_auth_enabled: bool + disable_vpn_on_oidc_error: bool + default_client_persistent_keepalive: int + default_client_mtu: int + default_client_endpoint: str | None + default_client_dns: list[str] + default_client_allowed_ips: list[str] + vpn_session_duration: int + logo_url: str | None + logo_type: str | None + inserted_at: datetime + updated_at: datetime + + +class ConfigurationUpdate(BaseModel): + allow_unprivileged_device_management: bool | None = None + allow_unprivileged_device_configuration: bool | None = None + local_auth_enabled: bool | None = None + disable_vpn_on_oidc_error: bool | None = None + default_client_persistent_keepalive: int | None = None + default_client_mtu: int | None = None + default_client_endpoint: str | None = None + default_client_dns: list[str] | None = None + default_client_allowed_ips: list[str] | None = None + vpn_session_duration: int | None = None + logo_url: str | None = None + logo_type: str | None = None diff --git a/wiregui/schemas/device.py b/wiregui/schemas/device.py new file mode 100644 index 0000000..62e7d6b --- /dev/null +++ b/wiregui/schemas/device.py @@ -0,0 +1,50 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class DeviceRead(BaseModel): + id: UUID + name: str + description: str | None + public_key: str + ipv4: str | None + ipv6: str | None + use_default_allowed_ips: bool + use_default_dns: bool + use_default_endpoint: bool + use_default_mtu: bool + use_default_persistent_keepalive: bool + endpoint: str | None + mtu: int | None + persistent_keepalive: int | None + allowed_ips: list[str] + dns: list[str] + rx_bytes: int | None + tx_bytes: int | None + latest_handshake: datetime | None + user_id: UUID + inserted_at: datetime + updated_at: datetime + + +class DeviceCreate(BaseModel): + name: str + description: str | None = None + user_id: UUID | None = None # admin can assign to another user + + +class DeviceUpdate(BaseModel): + name: str | None = None + description: str | None = None + use_default_allowed_ips: bool | None = None + use_default_dns: bool | None = None + use_default_endpoint: bool | None = None + use_default_mtu: bool | None = None + use_default_persistent_keepalive: bool | None = None + endpoint: str | None = None + mtu: int | None = None + persistent_keepalive: int | None = None + allowed_ips: list[str] | None = None + dns: list[str] | None = None diff --git a/wiregui/schemas/rule.py b/wiregui/schemas/rule.py new file mode 100644 index 0000000..d10cc4d --- /dev/null +++ b/wiregui/schemas/rule.py @@ -0,0 +1,31 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class RuleRead(BaseModel): + id: UUID + action: str + destination: str + port_type: str | None + port_range: str | None + user_id: UUID | None + inserted_at: datetime + updated_at: datetime + + +class RuleCreate(BaseModel): + action: str = "drop" + destination: str + port_type: str | None = None + port_range: str | None = None + user_id: UUID | None = None + + +class RuleUpdate(BaseModel): + action: str | None = None + destination: str | None = None + port_type: str | None = None + port_range: str | None = None + user_id: UUID | None = None diff --git a/wiregui/schemas/user.py b/wiregui/schemas/user.py new file mode 100644 index 0000000..9f63419 --- /dev/null +++ b/wiregui/schemas/user.py @@ -0,0 +1,28 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, EmailStr + + +class UserRead(BaseModel): + id: UUID + email: str + role: str + disabled_at: datetime | None + last_signed_in_at: datetime | None + last_signed_in_method: str | None + inserted_at: datetime + updated_at: datetime + + +class UserCreate(BaseModel): + email: str + password: str + role: str = "unprivileged" + + +class UserUpdate(BaseModel): + email: str | None = None + password: str | None = None + role: str | None = None + disabled_at: datetime | None = None diff --git a/wiregui/services/__init__.py b/wiregui/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wiregui/services/email.py b/wiregui/services/email.py new file mode 100644 index 0000000..e408a62 --- /dev/null +++ b/wiregui/services/email.py @@ -0,0 +1,51 @@ +"""Email sending via aiosmtplib for magic links and notifications.""" + +import aiosmtplib +from email.message import EmailMessage + +from loguru import logger + +from wiregui.config import get_settings + + +async def send_email(to: str, subject: str, body: str) -> bool: + """Send an email via configured SMTP. Returns True on success.""" + settings = get_settings() + + if not settings.smtp_host: + logger.warning("SMTP not configured — email to {} not sent", to) + return False + + msg = EmailMessage() + msg["From"] = settings.smtp_from + msg["To"] = to + msg["Subject"] = subject + msg.set_content(body) + + try: + await aiosmtplib.send( + msg, + hostname=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_user, + password=settings.smtp_password, + start_tls=True, + ) + logger.info("Email sent to {}: {}", to, subject) + return True + except Exception as e: + logger.error("Failed to send email to {}: {}", to, e) + return False + + +async def send_magic_link(to: str, link: str) -> bool: + """Send a magic link sign-in email.""" + subject = "WireGUI — Sign in link" + body = f"""You requested a sign-in link for WireGUI. + +Click here to sign in: +{link} + +This link expires in 15 minutes. If you didn't request this, you can safely ignore this email. +""" + return await send_email(to, subject, body) diff --git a/wiregui/services/events.py b/wiregui/services/events.py new file mode 100644 index 0000000..2136b67 --- /dev/null +++ b/wiregui/services/events.py @@ -0,0 +1,136 @@ +"""Event bridge — propagates database changes to WireGuard and firewall.""" + +from uuid import UUID + +from loguru import logger + +from wiregui.config import get_settings +from wiregui.db import async_session +from wiregui.models.device import Device +from wiregui.models.rule import Rule +from wiregui.services import firewall, wireguard + + +def _device_allowed_ips(device: Device) -> list[str]: + """Build the allowed-ips list for a device peer (its tunnel addresses).""" + ips = [] + if device.ipv4: + ips.append(f"{device.ipv4}/32") + if device.ipv6: + ips.append(f"{device.ipv6}/128") + return ips + + +# --- Device events --- + + +async def on_device_created(device: Device) -> None: + """Configure WireGuard peer and firewall after a new device is created.""" + settings = get_settings() + if not settings.wg_enabled: + return + try: + await wireguard.add_peer( + public_key=device.public_key, + allowed_ips=_device_allowed_ips(device), + preshared_key=device.preshared_key, + ) + except Exception as e: + logger.error("Failed to add WG peer for device {}: {}", device.name, e) + + try: + # Ensure user chain exists before adding jump rules + await firewall.add_user_chain(str(device.user_id)) + await firewall.add_device_jump_rule( + str(device.user_id), device.ipv4, device.ipv6, + ) + except Exception as e: + logger.error("Failed to add firewall jump rule for device {}: {}", device.name, e) + + +async def on_device_deleted(device: Device) -> None: + """Remove WireGuard peer after a device is deleted.""" + if not get_settings().wg_enabled: + return + try: + await wireguard.remove_peer(public_key=device.public_key) + except Exception as e: + logger.error("Failed to remove WG peer for device {}: {}", device.name, e) + # Firewall jump rules are cleaned up on next rebuild + + +async def on_device_updated(device: Device) -> None: + """Update WireGuard peer after a device is modified.""" + if not get_settings().wg_enabled: + return + try: + await wireguard.add_peer( + public_key=device.public_key, + allowed_ips=_device_allowed_ips(device), + preshared_key=device.preshared_key, + ) + except Exception as e: + logger.error("Failed to update WG peer for device {}: {}", device.name, e) + + +# --- Rule events --- + + +async def on_rule_created(rule: Rule) -> None: + """Apply a new firewall rule.""" + if not get_settings().wg_enabled: + return + if rule.user_id is None: + return # global rules handled via rebuild + try: + await firewall.apply_rule( + str(rule.user_id), rule.destination, rule.action, + rule.port_type, rule.port_range, + ) + except Exception as e: + logger.error("Failed to apply firewall rule {}: {}", rule.id, e) + + +async def on_rule_updated(rule: Rule) -> None: + """Firewall rule updated — rebuild the user's chain.""" + if not get_settings().wg_enabled: + return + if rule.user_id is None: + return + await _rebuild_user_chain(str(rule.user_id)) + + +async def on_rule_deleted(rule: Rule) -> None: + """Firewall rule removed — rebuild the user's chain.""" + if not get_settings().wg_enabled: + return + if rule.user_id is None: + return + await _rebuild_user_chain(str(rule.user_id)) + + +async def _rebuild_user_chain(user_id: str) -> None: + """Flush and rebuild a single user's firewall chain from current DB rules.""" + try: + from sqlmodel import select as sel + + async with async_session() as session: + rules = (await session.execute( + sel(Rule).where(Rule.user_id == UUID(user_id)) + )).scalars().all() + + devices = (await session.execute( + sel(Device).where(Device.user_id == UUID(user_id)) + )).scalars().all() + + await firewall.rebuild_all_rules([{ + "user_id": user_id, + "devices": [{"ipv4": d.ipv4, "ipv6": d.ipv6} for d in devices], + "rules": [ + {"destination": r.destination, "action": r.action, + "port_type": r.port_type, "port_range": r.port_range} + for r in rules + ], + }]) + except Exception as e: + logger.error("Failed to rebuild firewall chain for user {}: {}", user_id, e) diff --git a/wiregui/services/firewall.py b/wiregui/services/firewall.py new file mode 100644 index 0000000..924b6f1 --- /dev/null +++ b/wiregui/services/firewall.py @@ -0,0 +1,191 @@ +"""nftables firewall management — per-user chains and sets for device traffic filtering.""" + +import asyncio +import json + +from loguru import logger + +from wiregui.config import get_settings + +TABLE_NAME = "wiregui" + + +async def _nft(cmd: str) -> str: + """Run an nft command and return stdout.""" + proc = await asyncio.create_subprocess_exec( + "nft", *cmd.split(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + raise RuntimeError(f"nft {cmd} failed: {stderr.decode().strip()}") + return stdout.decode().strip() + + +async def _nft_batch(commands: list[str]) -> None: + """Run multiple nft commands in a single atomic batch.""" + batch = "\n".join(commands) + proc = await asyncio.create_subprocess_exec( + "nft", "-f", "-", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate(batch.encode()) + if proc.returncode != 0: + raise RuntimeError(f"nft batch failed: {stderr.decode().strip()}") + + +async def setup_base_tables() -> None: + """Create the base wiregui table with forward and postrouting chains.""" + commands = [ + f"add table inet {TABLE_NAME}", + # Forward chain for filtering device traffic + f"add chain inet {TABLE_NAME} forward {{ type filter hook forward priority 0; policy accept; }}", + # Postrouting for NAT/masquerade + f"add chain inet {TABLE_NAME} postrouting {{ type nat hook postrouting priority 100; policy accept; }}", + ] + try: + await _nft_batch(commands) + logger.info("Base nftables table '{}' created", TABLE_NAME) + except RuntimeError as e: + # Table may already exist + if "File exists" not in str(e): + raise + logger.debug("Base nftables table '{}' already exists", TABLE_NAME) + + +async def setup_masquerade(iface: str | None = None) -> None: + """Add masquerade rules for VPN traffic — NAT only traffic originating from WG subnets.""" + settings = get_settings() + iface = iface or settings.wg_interface + v4_net = settings.wg_ipv4_network + v6_net = settings.wg_ipv6_network + commands = [ + f"flush chain inet {TABLE_NAME} postrouting", + f'add rule inet {TABLE_NAME} postrouting ip saddr {v4_net} oifname != "{iface}" masquerade', + f'add rule inet {TABLE_NAME} postrouting ip6 saddr {v6_net} oifname != "{iface}" masquerade', + ] + try: + await _nft_batch(commands) + logger.info("Masquerade rule added for {}", iface) + except RuntimeError as e: + logger.debug("Masquerade setup: {}", e) + + +async def add_user_chain(user_id: str) -> None: + """Create a per-user chain for firewall rules.""" + chain = _user_chain_name(user_id) + commands = [ + f"add chain inet {TABLE_NAME} {chain}", + ] + try: + await _nft_batch(commands) + logger.debug("User chain created: {}", chain) + except RuntimeError as e: + if "File exists" not in str(e): + raise + + +async def remove_user_chain(user_id: str) -> None: + """Remove a per-user chain and all its rules.""" + chain = _user_chain_name(user_id) + try: + await _nft_batch([ + f"flush chain inet {TABLE_NAME} {chain}", + f"delete chain inet {TABLE_NAME} {chain}", + ]) + logger.debug("User chain removed: {}", chain) + except RuntimeError as e: + logger.debug("Remove user chain {}: {}", chain, e) + + +async def add_device_jump_rule(user_id: str, device_ipv4: str | None, device_ipv6: str | None) -> None: + """Add jump rules in the forward chain to route device traffic to the user chain.""" + chain = _user_chain_name(user_id) + commands = [] + if device_ipv4: + commands.append( + f"add rule inet {TABLE_NAME} forward ip saddr {device_ipv4} jump {chain}" + ) + if device_ipv6: + commands.append( + f"add rule inet {TABLE_NAME} forward ip6 saddr {device_ipv6} jump {chain}" + ) + if commands: + await _nft_batch(commands) + logger.debug("Jump rules added for device {}/{} -> {}", device_ipv4, device_ipv6, chain) + + +async def apply_rule(user_id: str, destination: str, action: str, port_type: str | None = None, port_range: str | None = None) -> None: + """Add a filter rule to a user's chain.""" + chain = _user_chain_name(user_id) + rule = _build_rule_expr(destination, action, port_type, port_range) + await _nft_batch([f"add rule inet {TABLE_NAME} {chain} {rule}"]) + logger.debug("Rule applied in {}: {} -> {}", chain, destination, action) + + +async def rebuild_all_rules(users_devices_rules: list[dict]) -> None: + """Full reconciliation: flush and rebuild all per-user chains from DB state. + + Args: + users_devices_rules: list of dicts with keys: + user_id, devices (list of {ipv4, ipv6}), rules (list of {destination, action, port_type, port_range}) + """ + commands = [] + + for entry in users_devices_rules: + user_id = entry["user_id"] + chain = _user_chain_name(user_id) + + # Create/flush user chain + commands.append(f"add chain inet {TABLE_NAME} {chain}") + commands.append(f"flush chain inet {TABLE_NAME} {chain}") + + # Add rules + for rule in entry.get("rules", []): + expr = _build_rule_expr( + rule["destination"], rule["action"], + rule.get("port_type"), rule.get("port_range"), + ) + commands.append(f"add rule inet {TABLE_NAME} {chain} {expr}") + + # Flush forward chain jump rules and re-add + commands.append(f"flush chain inet {TABLE_NAME} forward") + for entry in users_devices_rules: + user_id = entry["user_id"] + chain = _user_chain_name(user_id) + for dev in entry.get("devices", []): + if dev.get("ipv4"): + commands.append(f"add rule inet {TABLE_NAME} forward ip saddr {dev['ipv4']} jump {chain}") + if dev.get("ipv6"): + commands.append(f"add rule inet {TABLE_NAME} forward ip6 saddr {dev['ipv6']} jump {chain}") + + if commands: + await _nft_batch(commands) + logger.info("Firewall rules rebuilt for {} users", len(users_devices_rules)) + + +def _user_chain_name(user_id: str) -> str: + """Generate a deterministic chain name from a user ID.""" + # Use first 12 chars of UUID (without hyphens) to keep names short + short = user_id.replace("-", "")[:12] + return f"user_{short}" + + +def _build_rule_expr(destination: str, action: str, port_type: str | None = None, port_range: str | None = None) -> str: + """Build an nftables rule expression string.""" + # Determine IP version from destination + if ":" in destination: + addr_match = f"ip6 daddr {destination}" + else: + addr_match = f"ip daddr {destination}" + + parts = [addr_match] + + if port_type and port_range: + parts.append(f"{port_type} dport {port_range}") + + parts.append(action) + return " ".join(parts) diff --git a/wiregui/services/notifications.py b/wiregui/services/notifications.py new file mode 100644 index 0000000..2071717 --- /dev/null +++ b/wiregui/services/notifications.py @@ -0,0 +1,65 @@ +"""In-memory notification queue with severity levels.""" + +from collections import deque +from datetime import datetime +from typing import Any +from uuid import uuid4 + +from loguru import logger + +from wiregui.utils.time import utcnow + +MAX_NOTIFICATIONS = 100 + + +class Notification: + __slots__ = ("id", "severity", "message", "user", "timestamp") + + def __init__(self, severity: str, message: str, user: str | None = None): + self.id = str(uuid4()) + self.severity = severity # "info" | "warning" | "error" + self.message = message + self.user = user + self.timestamp = utcnow() + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "severity": self.severity, + "message": self.message, + "user": self.user, + "timestamp": self.timestamp.isoformat(), + } + + +_notifications: deque[Notification] = deque(maxlen=MAX_NOTIFICATIONS) + + +def add(severity: str, message: str, user: str | None = None) -> Notification: + """Add a notification to the queue.""" + n = Notification(severity, message, user) + _notifications.appendleft(n) + logger.debug("Notification added: [{}] {}", severity, message) + return n + + +def current() -> list[Notification]: + """Return all current notifications (newest first).""" + return list(_notifications) + + +def clear(notification_id: str) -> None: + """Remove a specific notification by ID.""" + for i, n in enumerate(_notifications): + if n.id == notification_id: + del _notifications[i] + return + + +def clear_all() -> None: + """Remove all notifications.""" + _notifications.clear() + + +def count() -> int: + return len(_notifications) diff --git a/wiregui/services/wireguard.py b/wiregui/services/wireguard.py new file mode 100644 index 0000000..6d29160 --- /dev/null +++ b/wiregui/services/wireguard.py @@ -0,0 +1,188 @@ +"""WireGuard interface management via subprocess calls to `wg` and `ip`.""" + +import asyncio +from dataclasses import dataclass, field +from datetime import datetime + +from loguru import logger + +from wiregui.config import get_settings + + +@dataclass +class PeerInfo: + public_key: str + endpoint: str | None = None + allowed_ips: list[str] = field(default_factory=list) + latest_handshake: datetime | None = None + rx_bytes: int = 0 + tx_bytes: int = 0 + + +async def _run(args: list[str], input_data: str | None = None) -> str: + """Run a subprocess and return stdout. Raises on non-zero exit.""" + proc = await asyncio.create_subprocess_exec( + *args, + stdin=asyncio.subprocess.PIPE if input_data else None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate(input_data.encode() if input_data else None) + if proc.returncode != 0: + raise RuntimeError(f"{' '.join(args)} failed (rc={proc.returncode}): {stderr.decode().strip()}") + return stdout.decode().strip() + + +async def ensure_interface(iface: str | None = None) -> None: + """Create WireGuard interface if it doesn't exist, assign server IPs and bring it up.""" + settings = get_settings() + iface = iface or settings.wg_interface + + # Check if interface exists + try: + await _run(["ip", "link", "show", iface]) + logger.debug("Interface {} already exists", iface) + return + except RuntimeError: + pass + + logger.info("Creating WireGuard interface {}", iface) + await _run(["ip", "link", "add", iface, "type", "wireguard"]) + + # Assign server IP (first host in each network) + from ipaddress import IPv4Network, IPv6Network + + v4_net = IPv4Network(settings.wg_ipv4_network, strict=False) + v4_server = str(list(v4_net.hosts())[0]) + await _run(["ip", "address", "add", f"{v4_server}/{v4_net.prefixlen}", "dev", iface]) + + v6_net = IPv6Network(settings.wg_ipv6_network, strict=False) + v6_server = str(list(v6_net.hosts())[0]) + await _run(["ip", "address", "add", f"{v6_server}/{v6_net.prefixlen}", "dev", iface]) + + await _run(["ip", "link", "set", iface, "up"]) + logger.info("Interface {} is up with {} and {}", iface, v4_server, v6_server) + + +async def configure_interface(iface: str | None = None) -> None: + """Set the server private key and listen port on the WireGuard interface from DB config.""" + from sqlmodel import select + + from wiregui.db import async_session + from wiregui.models.configuration import Configuration + + settings = get_settings() + iface = iface or settings.wg_interface + + async with async_session() as session: + result = await session.execute(select(Configuration).limit(1)) + config = result.scalar_one_or_none() + + if not config or not config.server_private_key: + logger.error("No server private key in Configuration — WG interface not configured") + return + + # Write private key to a temp file (stdin piping has issues with uvloop) + import tempfile + import os + + key_fd, key_path = tempfile.mkstemp() + try: + os.write(key_fd, config.server_private_key.encode()) + os.close(key_fd) + os.chmod(key_path, 0o600) + await _run(["wg", "set", iface, "private-key", key_path, "listen-port", str(settings.wg_endpoint_port)]) + finally: + os.unlink(key_path) + + logger.info("WireGuard interface {} configured (listen-port={})", iface, settings.wg_endpoint_port) + + +async def set_private_key(private_key_path: str, iface: str | None = None) -> None: + """Set the WireGuard private key from a file.""" + settings = get_settings() + iface = iface or settings.wg_interface + await _run(["wg", "set", iface, "private-key", private_key_path]) + + +async def set_listen_port(port: int, iface: str | None = None) -> None: + """Set the WireGuard listen port.""" + settings = get_settings() + iface = iface or settings.wg_interface + await _run(["wg", "set", iface, "listen-port", str(port)]) + + +async def add_peer( + public_key: str, + allowed_ips: list[str], + preshared_key: str | None = None, + iface: str | None = None, +) -> None: + """Add or update a WireGuard peer.""" + settings = get_settings() + iface = iface or settings.wg_interface + + args = ["wg", "set", iface, "peer", public_key, "allowed-ips", ",".join(allowed_ips)] + if preshared_key: + import tempfile + import os + + psk_fd, psk_path = tempfile.mkstemp() + try: + os.write(psk_fd, preshared_key.encode()) + os.close(psk_fd) + os.chmod(psk_path, 0o600) + await _run([ + "wg", "set", iface, "peer", public_key, + "allowed-ips", ",".join(allowed_ips), + "preshared-key", psk_path, + ]) + finally: + os.unlink(psk_path) + else: + await _run(args) + + logger.info("Peer added/updated: {} -> {}", public_key[:20], allowed_ips) + + +async def remove_peer(public_key: str, iface: str | None = None) -> None: + """Remove a WireGuard peer.""" + settings = get_settings() + iface = iface or settings.wg_interface + await _run(["wg", "set", iface, "peer", public_key, "remove"]) + logger.info("Peer removed: {}", public_key[:20]) + + +async def get_peers(iface: str | None = None) -> list[PeerInfo]: + """Parse `wg show dump` and return peer information.""" + settings = get_settings() + iface = iface or settings.wg_interface + + try: + output = await _run(["wg", "show", iface, "dump"]) + except RuntimeError: + return [] + + peers = [] + for line in output.splitlines()[1:]: # skip the interface line + parts = line.split("\t") + if len(parts) < 8: + continue + pub_key = parts[0] + # parts: public_key, preshared_key, endpoint, allowed_ips, latest_handshake, rx, tx, keepalive + endpoint = parts[2] if parts[2] != "(none)" else None + allowed_ips = parts[3].split(",") if parts[3] != "(none)" else [] + handshake_ts = int(parts[4]) if parts[4] != "0" else None + latest_handshake = datetime.utcfromtimestamp(handshake_ts) if handshake_ts else None + rx_bytes = int(parts[5]) + tx_bytes = int(parts[6]) + + peers.append(PeerInfo( + public_key=pub_key, + endpoint=endpoint, + allowed_ips=allowed_ips, + latest_handshake=latest_handshake, + rx_bytes=rx_bytes, + tx_bytes=tx_bytes, + )) + return peers diff --git a/wiregui/tasks/__init__.py b/wiregui/tasks/__init__.py new file mode 100644 index 0000000..9f12657 --- /dev/null +++ b/wiregui/tasks/__init__.py @@ -0,0 +1,28 @@ +"""Background tasks — registered on app startup.""" + +import asyncio + +from loguru import logger + +from wiregui.config import get_settings + +_tasks: list[asyncio.Task] = [] + + +def register_task(coro, name: str) -> None: + """Schedule a coroutine as a background task.""" + task = asyncio.create_task(coro, name=name) + _tasks.append(task) + logger.info("Background task registered: {}", name) + + +async def cancel_all() -> None: + """Cancel all registered background tasks (called on shutdown).""" + for task in _tasks: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + _tasks.clear() + logger.info("All background tasks cancelled") diff --git a/wiregui/tasks/connectivity.py b/wiregui/tasks/connectivity.py new file mode 100644 index 0000000..9cac99b --- /dev/null +++ b/wiregui/tasks/connectivity.py @@ -0,0 +1,60 @@ +"""Periodic WAN connectivity checks — fetch a URL and log the result.""" + +import asyncio + +import httpx +from loguru import logger +from sqlmodel import select + +from wiregui.db import async_session +from wiregui.models.configuration import Configuration +from wiregui.models.connectivity_check import ConnectivityCheck +from wiregui.services import notifications +from wiregui.utils.time import utcnow + +DEFAULT_URL = "https://ping-dev.firezone.dev" +DEFAULT_INTERVAL = 300 # 5 minutes + + +async def connectivity_loop() -> None: + """Run forever: perform connectivity checks at a configurable interval.""" + logger.info("Connectivity check task started") + await asyncio.sleep(60) # Initial delay to avoid startup spam + while True: + try: + await _check_connectivity() + except asyncio.CancelledError: + raise + except Exception as e: + logger.error("Connectivity check failed: {}", e) + await asyncio.sleep(DEFAULT_INTERVAL) + + +async def _check_connectivity() -> None: + """Fetch the connectivity check URL and store the result.""" + url = DEFAULT_URL + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(url) + + check = ConnectivityCheck( + url=url, + response_code=resp.status_code, + response_headers=dict(resp.headers), + response_body=resp.text[:500], + ) + logger.debug("Connectivity check: {} -> {}", url, resp.status_code) + + except Exception as e: + check = ConnectivityCheck( + url=url, + response_code=None, + response_body=str(e)[:500], + ) + logger.warning("Connectivity check failed: {}", e) + notifications.add("warning", f"WAN connectivity check failed: {e}") + + async with async_session() as session: + session.add(check) + await session.commit() diff --git a/wiregui/tasks/oidc_refresh.py b/wiregui/tasks/oidc_refresh.py new file mode 100644 index 0000000..ecc62dd --- /dev/null +++ b/wiregui/tasks/oidc_refresh.py @@ -0,0 +1,108 @@ +"""Periodically refresh OIDC tokens for all active connections.""" + +import asyncio + +from loguru import logger +from sqlmodel import select + +from wiregui.db import async_session +from wiregui.models.oidc_connection import OIDCConnection +from wiregui.models.user import User +from wiregui.services import notifications +from wiregui.utils.time import utcnow + +INTERVAL_SECONDS = 600 # 10 minutes + + +async def oidc_refresh_loop() -> None: + """Run forever: refresh OIDC tokens every INTERVAL_SECONDS.""" + logger.info("OIDC refresh task started (interval={}s)", INTERVAL_SECONDS) + await asyncio.sleep(60) # Initial delay to avoid startup spam + while True: + try: + await _refresh_all() + except asyncio.CancelledError: + raise + except Exception as e: + logger.error("OIDC refresh cycle failed: {}", e) + await asyncio.sleep(INTERVAL_SECONDS) + + +async def _refresh_all() -> None: + """Attempt to refresh all stored OIDC tokens.""" + from authlib.integrations.httpx_client import AsyncOAuth2Client + + from wiregui.auth.oidc import load_providers + + providers = await load_providers() + provider_map = {p["id"]: p for p in providers} + + async with async_session() as session: + result = await session.execute( + select(OIDCConnection).where(OIDCConnection.refresh_token.is_not(None)) + ) + connections = result.scalars().all() + + if not connections: + return + + refreshed = 0 + failed = 0 + + for conn in connections: + provider_config = provider_map.get(conn.provider) + if not provider_config: + continue + + try: + async with AsyncOAuth2Client( + client_id=provider_config["client_id"], + client_secret=provider_config["client_secret"], + ) as client: + # Load server metadata to get token endpoint + discovery_url = provider_config.get("discovery_document_uri") + if discovery_url: + import httpx + resp = await client.get(discovery_url) + metadata = resp.json() + token_endpoint = metadata.get("token_endpoint") + else: + continue + + new_token = await client.refresh_token( + url=token_endpoint, + refresh_token=conn.refresh_token, + ) + + # Update connection + async with async_session() as session: + c = await session.get(OIDCConnection, conn.id) + if c: + c.refresh_token = new_token.get("refresh_token", c.refresh_token) + c.refresh_response = dict(new_token) + c.refreshed_at = utcnow() + session.add(c) + await session.commit() + + refreshed += 1 + + except Exception as e: + failed += 1 + logger.warning("OIDC refresh failed for connection {} (provider={}): {}", + conn.id, conn.provider, e) + + # Check if we should disable VPN + from wiregui.models.configuration import Configuration + async with async_session() as session: + config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none() + if config and config.disable_vpn_on_oidc_error: + user = await session.get(User, conn.user_id) + if user: + notifications.add( + "error", + f"OIDC refresh failed for {user.email} ({conn.provider}). VPN access may be affected.", + user=user.email, + ) + + if refreshed or failed: + logger.info("OIDC refresh: {} succeeded, {} failed", refreshed, failed) diff --git a/wiregui/tasks/reconcile.py b/wiregui/tasks/reconcile.py new file mode 100644 index 0000000..3ed8773 --- /dev/null +++ b/wiregui/tasks/reconcile.py @@ -0,0 +1,90 @@ +"""Startup reconciliation — ensure WireGuard and firewall state matches the database.""" + +from loguru import logger +from sqlmodel import select + +from wiregui.db import async_session +from wiregui.models.device import Device +from wiregui.models.rule import Rule +from wiregui.services import firewall, wireguard + + +async def reconcile() -> None: + """Diff DB devices against WireGuard peers and apply corrections. + Also rebuild all firewall chains from DB state.""" + + # Get current WG peers + current_peers = await wireguard.get_peers() + current_keys = {p.public_key for p in current_peers} + + # Get all devices and rules from DB + async with async_session() as session: + devices = (await session.execute(select(Device))).scalars().all() + rules = (await session.execute(select(Rule))).scalars().all() + + expected_keys = {d.public_key for d in devices} + device_map = {d.public_key: d for d in devices} + + # Add missing peers (in DB but not in WG) + to_add = expected_keys - current_keys + for key in to_add: + device = device_map[key] + ips = [] + if device.ipv4: + ips.append(f"{device.ipv4}/32") + if device.ipv6: + ips.append(f"{device.ipv6}/128") + try: + await wireguard.add_peer( + public_key=device.public_key, + allowed_ips=ips, + preshared_key=device.preshared_key, + ) + except Exception as e: + logger.error("Reconcile: failed to add peer {}: {}", key[:20], e) + + # Remove orphaned peers (in WG but not in DB) + to_remove = current_keys - expected_keys + for key in to_remove: + try: + await wireguard.remove_peer(public_key=key) + except Exception as e: + logger.error("Reconcile: failed to remove peer {}: {}", key[:20], e) + + if to_add or to_remove: + logger.info("Reconciliation: added {} peers, removed {} orphans", len(to_add), len(to_remove)) + else: + logger.info("Reconciliation: WireGuard state matches database") + + # Rebuild all firewall rules from DB + await _reconcile_firewall(devices, rules) + + +async def _reconcile_firewall(devices: list[Device], rules: list[Rule]) -> None: + """Rebuild all firewall chains and jump rules from current DB state.""" + # Group devices and rules by user_id + user_ids = {str(d.user_id) for d in devices} + for r in rules: + if r.user_id: + user_ids.add(str(r.user_id)) + + entries = [] + for uid in user_ids: + user_devices = [d for d in devices if str(d.user_id) == uid] + user_rules = [r for r in rules if r.user_id and str(r.user_id) == uid] + + entries.append({ + "user_id": uid, + "devices": [{"ipv4": d.ipv4, "ipv6": d.ipv6} for d in user_devices], + "rules": [ + {"destination": r.destination, "action": r.action, + "port_type": r.port_type, "port_range": r.port_range} + for r in user_rules + ], + }) + + if entries: + try: + await firewall.rebuild_all_rules(entries) + except Exception as e: + logger.error("Reconcile: firewall rebuild failed: {}", e) diff --git a/wiregui/tasks/stats.py b/wiregui/tasks/stats.py new file mode 100644 index 0000000..ea26364 --- /dev/null +++ b/wiregui/tasks/stats.py @@ -0,0 +1,56 @@ +"""Periodically poll WireGuard peer stats and update device records.""" + +import asyncio + +from loguru import logger +from sqlmodel import select + +from wiregui.db import async_session +from wiregui.models.device import Device +from wiregui.services import wireguard + +INTERVAL_SECONDS = 60 + + +async def stats_loop() -> None: + """Run forever: poll WireGuard stats every INTERVAL_SECONDS.""" + logger.info("Stats task started (interval={}s)", INTERVAL_SECONDS) + while True: + try: + await _update_stats() + except asyncio.CancelledError: + raise + except Exception as e: + logger.error("Stats update failed: {}", e) + await asyncio.sleep(INTERVAL_SECONDS) + + +async def _update_stats() -> None: + """Fetch peer stats from WireGuard and update matching device rows.""" + peers = await wireguard.get_peers() + if not peers: + return + + peer_map = {p.public_key: p for p in peers} + + async with async_session() as session: + result = await session.execute( + select(Device).where(Device.public_key.in_(list(peer_map.keys()))) + ) + devices = result.scalars().all() + + updated = 0 + for device in devices: + peer = peer_map.get(device.public_key) + if peer is None: + continue + device.rx_bytes = peer.rx_bytes + device.tx_bytes = peer.tx_bytes + device.latest_handshake = peer.latest_handshake + device.remote_ip = peer.endpoint.split(":")[0] if peer.endpoint else None + session.add(device) + updated += 1 + + if updated: + await session.commit() + logger.debug("Updated stats for {} devices", updated) diff --git a/wiregui/tasks/vpn_session.py b/wiregui/tasks/vpn_session.py new file mode 100644 index 0000000..661e54b --- /dev/null +++ b/wiregui/tasks/vpn_session.py @@ -0,0 +1,74 @@ +"""Expire VPN sessions — remove WireGuard peers for users whose session has timed out.""" + +import asyncio + +from loguru import logger +from sqlmodel import select + +from wiregui.db import async_session +from wiregui.models.configuration import Configuration +from wiregui.models.device import Device +from wiregui.models.user import User +from wiregui.services import notifications, wireguard +from wiregui.utils.time import utcnow + +INTERVAL_SECONDS = 60 + + +async def vpn_session_loop() -> None: + """Run forever: check for expired VPN sessions every INTERVAL_SECONDS.""" + logger.info("VPN session expiry task started (interval={}s)", INTERVAL_SECONDS) + while True: + try: + await _expire_sessions() + except asyncio.CancelledError: + raise + except Exception as e: + logger.error("VPN session check failed: {}", e) + await asyncio.sleep(INTERVAL_SECONDS) + + +async def _expire_sessions() -> None: + """Find users with expired VPN sessions and remove their WireGuard peers.""" + async with async_session() as session: + config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none() + if not config or config.vpn_session_duration == 0: + return # 0 = unlimited, no expiry + + duration_seconds = config.vpn_session_duration + now = utcnow() + + # Find users whose last sign-in was more than duration_seconds ago + result = await session.execute(select(User).where(User.last_signed_in_at.is_not(None))) + users = result.scalars().all() + + expired_count = 0 + for user in users: + if user.disabled_at is not None: + continue + + elapsed = (now - user.last_signed_in_at).total_seconds() + if elapsed <= duration_seconds: + continue + + # Session expired — remove all user's device peers + devices = (await session.execute( + select(Device).where(Device.user_id == user.id) + )).scalars().all() + + for device in devices: + try: + await wireguard.remove_peer(public_key=device.public_key) + except Exception as e: + logger.debug("Failed to remove expired peer {}: {}", device.public_key[:16], e) + + expired_count += 1 + logger.info("VPN session expired for {} ({} devices removed)", user.email, len(devices)) + notifications.add( + "warning", + f"VPN session expired for {user.email}", + user=user.email, + ) + + if expired_count: + logger.info("Expired {} user VPN sessions", expired_count) diff --git a/wiregui/utils/__init__.py b/wiregui/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wiregui/utils/crypto.py b/wiregui/utils/crypto.py new file mode 100644 index 0000000..f9d14ef --- /dev/null +++ b/wiregui/utils/crypto.py @@ -0,0 +1,31 @@ +"""WireGuard key generation and encryption utilities.""" + +import base64 +import os +import subprocess + + +def generate_private_key() -> str: + """Generate a WireGuard private key using `wg genkey`.""" + result = subprocess.run(["wg", "genkey"], capture_output=True, text=True, check=True) + return result.stdout.strip() + + +def derive_public_key(private_key: str) -> str: + """Derive a WireGuard public key from a private key using `wg pubkey`.""" + result = subprocess.run( + ["wg", "pubkey"], input=private_key, capture_output=True, text=True, check=True + ) + return result.stdout.strip() + + +def generate_keypair() -> tuple[str, str]: + """Generate a WireGuard keypair. Returns (private_key, public_key).""" + private_key = generate_private_key() + public_key = derive_public_key(private_key) + return private_key, public_key + + +def generate_preshared_key() -> str: + """Generate a WireGuard preshared key (32 random bytes, base64-encoded).""" + return base64.b64encode(os.urandom(32)).decode() diff --git a/wiregui/utils/network.py b/wiregui/utils/network.py new file mode 100644 index 0000000..56f5266 --- /dev/null +++ b/wiregui/utils/network.py @@ -0,0 +1,62 @@ +"""IP address allocation for WireGuard tunnel addresses.""" + +import random +from ipaddress import IPv4Network, IPv6Network, ip_address + +from loguru import logger +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from wiregui.models.device import Device + + +async def allocate_ipv4(session: AsyncSession, network_cidr: str) -> str: + """Find the next available IPv4 address in the given CIDR range.""" + network = IPv4Network(network_cidr, strict=False) + used = await _get_used_ips(session, "ipv4") + return _find_available(network, used) + + +async def allocate_ipv6(session: AsyncSession, network_cidr: str) -> str: + """Find the next available IPv6 address in the given CIDR range.""" + network = IPv6Network(network_cidr, strict=False) + used = await _get_used_ips(session, "ipv6") + return _find_available(network, used) + + +async def _get_used_ips(session: AsyncSession, field: str) -> set[str]: + """Get all IPs currently assigned to devices for the given field.""" + col = Device.ipv4 if field == "ipv4" else Device.ipv6 + result = await session.execute(select(col).where(col.is_not(None))) + return {row[0] for row in result.all()} + + +def _find_available(network: IPv4Network | IPv6Network, used: set[str]) -> str: + """Find an available IP in the network, starting from a random offset.""" + hosts = list(network.hosts()) + if not hosts: + raise ValueError(f"No usable hosts in {network}") + + # Skip the first host (gateway/server address) + hosts = hosts[1:] + if not hosts: + raise ValueError(f"No usable hosts in {network} after reserving gateway") + + # Start from a random offset, then scan forward and backward + start = random.randint(0, len(hosts) - 1) + + # Forward scan + for i in range(start, len(hosts)): + candidate = str(hosts[i]) + if candidate not in used: + logger.debug("Allocated {} from {}", candidate, network) + return candidate + + # Backward scan + for i in range(start - 1, -1, -1): + candidate = str(hosts[i]) + if candidate not in used: + logger.debug("Allocated {} from {}", candidate, network) + return candidate + + raise ValueError(f"No available addresses in {network}") diff --git a/wiregui/utils/server_key.py b/wiregui/utils/server_key.py new file mode 100644 index 0000000..3a4157a --- /dev/null +++ b/wiregui/utils/server_key.py @@ -0,0 +1,19 @@ +"""Retrieve the server's WireGuard public key from the Configuration table.""" + +from loguru import logger +from sqlmodel import select + +from wiregui.db import async_session +from wiregui.models.configuration import Configuration + + +async def get_server_public_key() -> str: + """Get the server's WireGuard public key. Raises if not configured.""" + async with async_session() as session: + result = await session.execute(select(Configuration).limit(1)) + config = result.scalar_one_or_none() + + if config and config.server_public_key: + return config.server_public_key + + raise RuntimeError("Server WireGuard keypair not configured — run setup or check wg CLI availability") diff --git a/wiregui/utils/time.py b/wiregui/utils/time.py new file mode 100644 index 0000000..9efe8ba --- /dev/null +++ b/wiregui/utils/time.py @@ -0,0 +1,6 @@ +from datetime import UTC, datetime + + +def utcnow() -> datetime: + """Return current UTC time as a naive datetime (for Postgres TIMESTAMP WITHOUT TIME ZONE).""" + return datetime.now(UTC).replace(tzinfo=None) diff --git a/wiregui/utils/wg_conf.py b/wiregui/utils/wg_conf.py new file mode 100644 index 0000000..bd3217b --- /dev/null +++ b/wiregui/utils/wg_conf.py @@ -0,0 +1,54 @@ +"""Build WireGuard client configuration files.""" + +from wiregui.config import get_settings +from wiregui.models.device import Device + + +def build_client_config( + device: Device, + private_key: str, + server_public_key: str, +) -> str: + """Build a WireGuard [Interface]+[Peer] config string for a device.""" + settings = get_settings() + + # Resolve per-device or default values + dns = device.dns if not device.use_default_dns else settings.wg_dns + endpoint_host = device.endpoint if not device.use_default_endpoint else settings.wg_endpoint_host + mtu = device.mtu if not device.use_default_mtu else settings.wg_mtu + keepalive = device.persistent_keepalive if not device.use_default_persistent_keepalive else settings.wg_persistent_keepalive + allowed_ips = device.allowed_ips if not device.use_default_allowed_ips else settings.wg_allowed_ips + + # Build address list + addresses = [] + if device.ipv4: + addresses.append(f"{device.ipv4}/32") + if device.ipv6: + addresses.append(f"{device.ipv6}/128") + + # Build endpoint + endpoint_port = settings.wg_endpoint_port + endpoint = f"{endpoint_host}:{endpoint_port}" + + lines = ["[Interface]", f"PrivateKey = {private_key}"] + if addresses: + lines.append(f"Address = {', '.join(addresses)}") + if dns: + dns_str = dns if isinstance(dns, str) else ", ".join(dns) + lines.append(f"DNS = {dns_str}") + if mtu: + lines.append(f"MTU = {mtu}") + + lines.append("") + lines.append("[Peer]") + lines.append(f"PublicKey = {server_public_key}") + if device.preshared_key: + lines.append(f"PresharedKey = {device.preshared_key}") + if allowed_ips: + ips_str = allowed_ips if isinstance(allowed_ips, str) else ", ".join(allowed_ips) + lines.append(f"AllowedIPs = {ips_str}") + lines.append(f"Endpoint = {endpoint}") + if keepalive: + lines.append(f"PersistentKeepalive = {keepalive}") + + return "\n".join(lines) + "\n"