diff --git a/README.md b/README.md index e47cd5a..596641c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,22 @@ For the optional DRF views: pip install "siwe-django[drf]" ``` +For OpenAPI schemas (auto-generated by drf-spectacular): + +```bash +pip install "siwe-django[drf,openapi]" +``` + +For the setup wizard CLI: + +```bash +pip install "siwe-django[cli]" +siwe-django init # patch settings.py + urls.py + drop a template +siwe-django doctor # diagnose an existing install (CI-friendly --json) +siwe-django scaffold-templates # add the bundled sign-in template +siwe-django migrate-from-payton # rewrite payton/django-siwe-auth references +``` + ## Configure ```python @@ -76,7 +92,8 @@ python manage.py migrate - `GET /nonce/`: returns `{ nonce, expiresAt, domain, uri, statement, ethereumIdentityKit }` and binds the nonce to the current Django session. - `POST /verify/`: accepts `{ message, signature }`, verifies the SIWE message - with strict domain, URI, chain, and nonce checks, logs in the user, and returns + with strict domain, URI, chain, nonce, and bound optional-field + (`Resources`, `Request ID`, `Not Before`) checks, logs in the user, and returns user and wallet data. - `GET /me/`: returns the current authenticated SIWE identity. - `POST /logout/`: destroys the Django session. @@ -85,6 +102,9 @@ python manage.py migrate - `DELETE /wallets//`: unlinks a wallet. - `GET /profile//`: proxies a display-ready Ethereum Identity Kit profile from the Eth Follow public API. +- `POST /reauth/`: re-verifies a SIWE signature for the currently authenticated + user (step-up). Stamps the session so `@require_recent_siwe(seconds=N)` can + gate sensitive actions. ## Frontend Flow @@ -97,6 +117,47 @@ python manage.py migrate The server consumes each nonce after the first successful verification, so replay attempts fail. +### Optional EIP-4361 fields + +`siwe_django.services.issue_nonce` accepts `resources`, `request_id`, and +`not_before` keyword arguments and binds them to the issued nonce. When the +client signs a message that uses these fields, `verify_siwe_message` enforces +that: + +- the signed `Resources` are a subset of the issued `resources`, +- the signed `Request ID` matches the bound value, +- the signed `Not Before` matches the bound timestamp. + +### ReCap (ERC-5573) + +`siwe_django.recap` ships helpers to build and parse ReCap capability URIs so +relying parties can scope a sign-in to specific abilities: + +```python +from siwe_django.recap import encode_recap +from siwe_django.services import issue_nonce + +recap_uri = encode_recap({"https://api.example.com": {"crud/read": [{}]}}) +issue_nonce(request, resources=["https://api.example.com", recap_uri]) +``` + +`siwe_django.recap.find_recap_in_resources(resources)` returns the decoded +`{"att": ..., "prf": ...}` payload from a SIWE message's `Resources` list, or +`None` when no ReCap is present. + +### Smart contract wallets + +`siwe-django` verifies smart contract wallet signatures via: + +- **EIP-1271** for already-deployed contract wallets (Safe, multisigs, …). +- **EIP-6492** for counterfactual wallets that have not yet been deployed + (Coinbase Smart Wallet, Privy, …). The upstream `signinwithethereum` + library calls the EIP-6492 universal validator over `eth_call` so we never + need a deployed contract. + +Both paths require `RPC_URLS` to contain a provider for the wallet's chain. +Without it the contract check fails and the request is rejected. + ## Showcase Demo The repository includes a full Django + Vite React demo under @@ -196,6 +257,7 @@ All settings live under `SIWE_DJANGO`. | `URI` | request root URI | Expected SIWE URI. | | `STATEMENT` | `"Sign in with Ethereum."` | Human-readable statement for clients. | | `NONCE_TTL_SECONDS` | `300` | Nonce lifetime. | +| `CLOCK_SKEW_SECONDS` | `60` | Tolerance applied to `Issued At`, `Not Before`, and `Expiration Time` checks. Set to `0` for strict comparison. | | `ALLOWED_CHAIN_IDS` | `None` | Optional allow-list for message chain IDs. | | `RPC_URLS` | `{}` | Chain ID to RPC URL map for contract wallet and token checks. | | `ENS_ENABLED` | `False` | Enable ENS name/avatar lookup. | @@ -209,6 +271,11 @@ All settings live under `SIWE_DJANGO`. | `USER_FACTORY` | built-in | Dotted path for custom user creation. | | `RATE_LIMITS` | `{}` | Optional per-view limits like `{ "verify": "5/m" }`. | | `RATE_LIMIT_TRUST_X_FORWARDED_FOR` | `False` | Use the first `X-Forwarded-For` address for rate limits. Enable only behind a trusted proxy that strips client-supplied forwarding headers. | +| `AUDIT_ENABLED` | `True` | Persist sign-in events to `SiweAuthEvent`. Disable to forward audit data through your own pipeline. | +| `NONCE_STORE` | `siwe_django.nonce_store.DjangoOrmNonceStore` | Dotted path to the nonce store class. Swap for `siwe_django.nonce_store.RedisNonceStore` (extra: `pip install "siwe-django[redis]"`) for a Redis-backed store. | +| `REDIS_URL` | `None` | Used by `RedisNonceStore` when no client is injected. | +| `WEBHOOKS` | `[]` | Subscribers shaped `{event, url, secret, timeout?}`. `event: "*"` matches every audit event. Bodies are HMAC-SHA256 signed in the `X-Siwe-Signature` header. | +| `WEBHOOK_DISPATCHER` | `None` | Dotted path to a callable `(event, payload, subscriptions)` invoked instead of the synchronous urllib delivery (use to wire Celery / RQ). | | `TOKEN_GATES` | `[]` | Optional group sync gates. | | `SYNC_TOKEN_GATES_ON_LOGIN` | `True` | Sync token gates after login/linking. | @@ -239,6 +306,38 @@ SIWE_DJANGO = { Custom checkers receive `wallet` and `gate` keyword arguments and return a boolean. +### EFP and ENS gates + +Gates are not limited to on-chain holdings. The Ethereum Identity Kit / EFP +graph is a first-class authorization primitive: + +```python +SIWE_DJANGO = { + "ETHID_ENABLED": True, + "TOKEN_GATES": [ + {"type": "efp_followed_by", "source": "team.example.eth", "group": "team"}, + {"type": "efp_min_followers", "threshold": 100, "group": "popular"}, + {"type": "efp_tag", "source": "team.example.eth", "tag": "vip", "group": "vip"}, + {"type": "efp_not_blocked_by", "source": "team.example.eth", "group": "members"}, + {"type": "ens_required", "group": "ens-holders"}, + ], +} +``` + +| Type | Passes when | +| --- | --- | +| `efp_follower_of` | wallet follows `target` | +| `efp_followed_by` | `source` follows the wallet | +| `efp_mutual` | wallet and `hub` follow each other | +| `efp_min_followers` | wallet has at least `threshold` followers | +| `efp_tag` | `source` has tagged the wallet with `tag` | +| `efp_not_blocked_by` | `source` has not blocked or muted the wallet | +| `ens_required` | wallet has a primary ENS name | + +EFP and ENS gates ignore `chain_id`. They reuse the existing `TOKEN_GATES` +group-sync semantics, so a failed gate removes the matching `Group` rather +than blocking sign-in. + ## OIDC Helpers `siwe_django.oidc.claims_for_wallet(wallet)` returns claim shapes compatible with diff --git a/examples/showcase/.dockerignore b/examples/showcase/.dockerignore new file mode 100644 index 0000000..9f53f4d --- /dev/null +++ b/examples/showcase/.dockerignore @@ -0,0 +1,37 @@ +# The Dockerfile is built with the repo root as context. Excluding heavy +# directories keeps the build fast and the final image small. +**/__pycache__ +**/.pytest_cache +**/.ruff_cache +**/*.pyc +**/.DS_Store + +# Virtual envs / lockfile sidecars +.venv/ +venv/ +.tox/ +dist/ +build/ +*.egg-info/ + +# Frontend build artefacts (we rebuild inside the container) +examples/showcase/frontend/node_modules/ +examples/showcase/frontend/dist/ + +# Local DBs / showcase volumes +**/db.sqlite3 +**/db.sqlite3-journal + +# Local plans / IDE / git +.git/ +.github/ +.context/ +.vscode/ +.idea/ +plans/ + +# Other examples we don't deploy +examples/templates_demo/ + +# Tests / docs not needed in the runtime image +tests/ diff --git a/examples/showcase/Dockerfile b/examples/showcase/Dockerfile new file mode 100644 index 0000000..c3ca5d6 --- /dev/null +++ b/examples/showcase/Dockerfile @@ -0,0 +1,67 @@ +# syntax=docker/dockerfile:1.7 +# +# Multi-stage build for the siwe-django showcase. +# +# stage 1: build the Vite/React frontend with bun, output dist/ -> /frontend +# stage 2: install siwe-django + showcase deps, copy the built dist into +# backend/showcase/static_frontend/, run gunicorn behind whitenoise +# +# Build context = repo root, e.g. +# fly deploy --config examples/showcase/fly.toml --dockerfile examples/showcase/Dockerfile +# + +# ----------------------------------------------------------------------------- +# Stage 1: frontend +# ----------------------------------------------------------------------------- +FROM oven/bun:1 AS frontend +WORKDIR /frontend + +ENV VITE_BASE=/static/ + +COPY examples/showcase/frontend/package.json examples/showcase/frontend/package-lock.json* ./ +RUN bun install --frozen-lockfile || bun install + +COPY examples/showcase/frontend/ ./ +RUN bun run build + +# ----------------------------------------------------------------------------- +# Stage 2: runtime +# ----------------------------------------------------------------------------- +FROM python:3.13-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PORT=8080 \ + DJANGO_SETTINGS_MODULE=showcase.settings \ + SIWE_DEMO_DEBUG=false \ + SIWE_DEMO_SECURE_COOKIES=true \ + SIWE_DEMO_USE_X_FORWARDED_PROTO=true \ + SIWE_DEMO_DATABASE_PATH=/data/db.sqlite3 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential curl \ + && rm -rf /var/lib/apt/lists/* + +# Install siwe-django (this repo) + production server deps. Copy only what +# pip needs first so the heavy install layer stays cached when only the +# showcase app changes. +COPY pyproject.toml README.md ./ +COPY src/ ./src/ +RUN pip install --upgrade pip \ + && pip install ".[drf]" "gunicorn>=22" "whitenoise[brotli]>=6" + +# Showcase code + the built frontend bundle +COPY examples/showcase/ ./examples/showcase/ +COPY --from=frontend /frontend/dist ./examples/showcase/backend/showcase/static_frontend + +WORKDIR /app/examples/showcase/backend +ENV PYTHONPATH=/app/examples/showcase/backend + +RUN chmod +x /app/examples/showcase/entrypoint.sh \ + && python manage.py collectstatic --noinput + +EXPOSE 8080 +CMD ["/app/examples/showcase/entrypoint.sh"] diff --git a/examples/showcase/README.md b/examples/showcase/README.md index 3f5e880..833a0dc 100644 --- a/examples/showcase/README.md +++ b/examples/showcase/README.md @@ -59,3 +59,39 @@ uv run pytest uv run python -m build cd examples/showcase/frontend && npm run build ``` + +## Deploy to Fly.io + +The showcase ships a multi-stage `Dockerfile` (Bun builds the Vite bundle, +Python runs `gunicorn` behind WhiteNoise) and a `fly.toml` that mounts a +1 GB volume for SQLite. One Fly app serves the React SPA and the Django +endpoints under a single origin so CSRF and session cookies stay +same-origin. + +From the repository root: + +```bash +# 1) Edit examples/showcase/fly.toml: set `app = ""` and the +# SIWE_DEMO_ALLOWED_HOSTS / SIWE_DEMO_DOMAIN / SIWE_DEMO_URI / +# SIWE_DEMO_CSRF_TRUSTED_ORIGINS values to match the Fly hostname you +# intend to use (or your custom domain). + +# 2) Create the app + the SQLite volume. +fly launch --config examples/showcase/fly.toml --no-deploy +fly volumes create showcase_data --region lhr --size 1 + +# 3) Set the secrets (everything not in `fly.toml`'s [env]). +fly secrets set \ + SIWE_DEMO_SECRET_KEY="$(openssl rand -hex 32)" \ + SIWE_DEMO_RPC_URL_1="https://mainnet.infura.io/v3/" + +# 4) Deploy. +fly deploy --config examples/showcase/fly.toml +``` + +Hit `https://.fly.dev/` and the React SPA loads; sign-in, +session, wallet linking, and token-gate sync all work end-to-end. + +To switch off SQLite-on-volume in favour of managed Postgres, swap the +`DATABASES` block in `backend/showcase/settings.py` for one that reads +`DATABASE_URL` and remove the `[[mounts]]` section from `fly.toml`. diff --git a/examples/showcase/backend/showcase/settings.py b/examples/showcase/backend/showcase/settings.py index b1f9108..0639581 100644 --- a/examples/showcase/backend/showcase/settings.py +++ b/examples/showcase/backend/showcase/settings.py @@ -8,11 +8,26 @@ BASE_DIR = Path(__file__).resolve().parent.parent REPO_ROOT = BASE_DIR.parents[2] + +def _bool(name: str, default: str = "false") -> bool: + return os.getenv(name, default).lower() in {"1", "true", "yes"} + + +def _csv(name: str, default: str = "") -> list[str]: + raw = os.getenv(name, default) + return [item.strip() for item in raw.split(",") if item.strip()] + + SECRET_KEY = os.getenv("SIWE_DEMO_SECRET_KEY", "siwe-django-showcase-dev-key") -DEBUG = os.getenv("SIWE_DEMO_DEBUG", "true").lower() in {"1", "true", "yes"} +DEBUG = _bool("SIWE_DEMO_DEBUG", "true") -ALLOWED_HOSTS = ["localhost", "127.0.0.1", "testserver"] -CSRF_TRUSTED_ORIGINS = ["http://localhost:5173", "http://127.0.0.1:5173"] +ALLOWED_HOSTS = _csv( + "SIWE_DEMO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver" +) +CSRF_TRUSTED_ORIGINS = _csv( + "SIWE_DEMO_CSRF_TRUSTED_ORIGINS", + "http://localhost:5173,http://127.0.0.1:5173", +) INSTALLED_APPS = [ "django.contrib.auth", @@ -23,8 +38,20 @@ "siwe_django", ] +try: + import whitenoise # noqa: F401 + + _HAS_WHITENOISE = True +except ImportError: + _HAS_WHITENOISE = False + MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", + *( + ["whitenoise.middleware.WhiteNoiseMiddleware"] + if _HAS_WHITENOISE + else [] + ), "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", ] @@ -33,10 +60,28 @@ WSGI_APPLICATION = "showcase.wsgi.application" ASGI_APPLICATION = "showcase.asgi.application" +_FRONTEND_DIST = BASE_DIR / "showcase" / "static_frontend" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [_FRONTEND_DIST] if _FRONTEND_DIST.exists() else [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + ], + }, + }, +] + DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "NAME": Path( + os.getenv("SIWE_DEMO_DATABASE_PATH", str(BASE_DIR / "db.sqlite3")) + ), } } @@ -50,11 +95,27 @@ USE_I18N = True USE_TZ = True -STATIC_URL = "static/" +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_DIRS = [_FRONTEND_DIST] if _FRONTEND_DIST.exists() else [] +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": ( + "whitenoise.storage.CompressedManifestStaticFilesStorage" + if (_HAS_WHITENOISE and not DEBUG) + else "django.contrib.staticfiles.storage.StaticFilesStorage" + ) + }, +} DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" SESSION_COOKIE_SAMESITE = "Lax" CSRF_COOKIE_SAMESITE = "Lax" +SESSION_COOKIE_SECURE = _bool("SIWE_DEMO_SECURE_COOKIES") +CSRF_COOKIE_SECURE = _bool("SIWE_DEMO_SECURE_COOKIES") +if _bool("SIWE_DEMO_USE_X_FORWARDED_PROTO"): + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") PASSWORD_HASHERS = [ "django.contrib.auth.hashers.MD5PasswordHasher", @@ -71,16 +132,15 @@ def _rpc_urls() -> dict[int, str]: SIWE_DJANGO = { - "DOMAIN": "localhost:5173", - "URI": "http://localhost:5173/", + "DOMAIN": os.getenv("SIWE_DEMO_DOMAIN", "localhost:5173"), + "URI": os.getenv("SIWE_DEMO_URI", "http://localhost:5173/"), "STATEMENT": "Sign in to the siwe-django showcase.", "ALLOWED_CHAIN_IDS": [1, 11155111, 31337], "AUTO_CREATE_USERS": True, "USER_FACTORY": "showcase.auth.demo_user_factory", "ENS_ENABLED": bool(os.getenv("SIWE_DEMO_ENS_RPC_URL")), "ENS_RPC_URL": os.getenv("SIWE_DEMO_ENS_RPC_URL"), - "ETHID_ENABLED": os.getenv("SIWE_DEMO_ETHID_ENABLED", "true").lower() - in {"1", "true", "yes"}, + "ETHID_ENABLED": _bool("SIWE_DEMO_ETHID_ENABLED", "true"), "ETHID_PROFILE_PROXY_ENABLED": True, "RPC_URLS": _rpc_urls(), "TOKEN_GATES": [ diff --git a/examples/showcase/backend/showcase/urls.py b/examples/showcase/backend/showcase/urls.py index 00e9bd3..b4fdf47 100644 --- a/examples/showcase/backend/showcase/urls.py +++ b/examples/showcase/backend/showcase/urls.py @@ -1,10 +1,11 @@ from __future__ import annotations -from django.urls import include, path +from django.urls import include, path, re_path from . import views urlpatterns = [ path("auth/siwe/", include("siwe_django.urls")), path("api/showcase/session/", views.session, name="showcase-session"), + re_path(r"^(?P.*)$", views.spa, name="showcase-spa"), ] diff --git a/examples/showcase/backend/showcase/views.py b/examples/showcase/backend/showcase/views.py index fbffec3..4512e5c 100644 --- a/examples/showcase/backend/showcase/views.py +++ b/examples/showcase/backend/showcase/views.py @@ -1,7 +1,16 @@ from __future__ import annotations +from pathlib import Path + +from django.conf import settings from django.contrib.auth.models import AnonymousUser -from django.http import HttpRequest, JsonResponse +from django.http import ( + FileResponse, + HttpRequest, + HttpResponse, + HttpResponseNotFound, + JsonResponse, +) from django.views.decorators.http import require_http_methods from siwe_django.gates import sync_wallet_groups @@ -43,6 +52,30 @@ def _empty_session() -> dict: } +def _index_path() -> Path | None: + candidates = [ + Path(directory) / "index.html" + for directory in getattr(settings, "STATICFILES_DIRS", []) + ] + static_root = getattr(settings, "STATIC_ROOT", None) + if static_root: + candidates.append(Path(static_root) / "index.html") + return next((path for path in candidates if path.exists()), None) + + +@require_http_methods(["GET"]) +def spa(request: HttpRequest, path: str = "") -> HttpResponse: + """Serve the built React app for any unmatched path. + + Returns 404 in dev (no built frontend present) so the Vite dev server is + the canonical SPA host during local development. + """ + index = _index_path() + if index is None: + return HttpResponseNotFound("Frontend bundle not found.") + return FileResponse(index.open("rb"), content_type="text/html") + + @require_http_methods(["GET"]) def session(request: HttpRequest) -> JsonResponse: user = request.user diff --git a/examples/showcase/entrypoint.sh b/examples/showcase/entrypoint.sh new file mode 100755 index 0000000..56e27aa --- /dev/null +++ b/examples/showcase/entrypoint.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Ensure the SQLite parent directory exists when running on a Fly volume. +DB_DIR="$(dirname "${SIWE_DEMO_DATABASE_PATH:-/data/db.sqlite3}")" +mkdir -p "$DB_DIR" + +python manage.py migrate --noinput + +exec gunicorn showcase.wsgi:application \ + --bind "0.0.0.0:${PORT:-8080}" \ + --workers "${WEB_CONCURRENCY:-2}" \ + --timeout "${GUNICORN_TIMEOUT:-30}" \ + --access-logfile - \ + --error-logfile - diff --git a/examples/showcase/fly.toml b/examples/showcase/fly.toml new file mode 100644 index 0000000..4ab7bf9 --- /dev/null +++ b/examples/showcase/fly.toml @@ -0,0 +1,56 @@ +# Fly.io config for the siwe-django showcase. +# +# The build context is the repo root; deploy with: +# +# fly launch --config examples/showcase/fly.toml --no-deploy # one-off +# fly volumes create showcase_data --region lhr --size 1 +# fly secrets set \ +# SIWE_DEMO_SECRET_KEY=$(openssl rand -hex 32) \ +# SIWE_DEMO_RPC_URL_1=https://mainnet.infura.io/v3/... +# fly deploy --config examples/showcase/fly.toml +# +# Update `app` to your unique Fly app name before the first deploy. + +app = "siwe-django-showcase" +primary_region = "lhr" + +[build] + dockerfile = "examples/showcase/Dockerfile" + +[env] + PORT = "8080" + DJANGO_SETTINGS_MODULE = "showcase.settings" + SIWE_DEMO_DEBUG = "false" + SIWE_DEMO_SECURE_COOKIES = "true" + SIWE_DEMO_USE_X_FORWARDED_PROTO = "true" + SIWE_DEMO_DATABASE_PATH = "/data/db.sqlite3" + SIWE_DEMO_ALLOWED_HOSTS = "siwe-django-showcase.fly.dev" + SIWE_DEMO_CSRF_TRUSTED_ORIGINS = "https://siwe-django-showcase.fly.dev" + SIWE_DEMO_DOMAIN = "siwe-django-showcase.fly.dev" + SIWE_DEMO_URI = "https://siwe-django-showcase.fly.dev/" + SIWE_DEMO_ETHID_ENABLED = "true" + +[[mounts]] + source = "showcase_data" + destination = "/data" + initial_size = "1gb" + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] + + [http_service.concurrency] + type = "connections" + hard_limit = 200 + soft_limit = 100 + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" + +[deploy] + release_command = "python manage.py migrate --noinput" diff --git a/examples/showcase/frontend/vite.config.ts b/examples/showcase/frontend/vite.config.ts index 23dd538..676a662 100644 --- a/examples/showcase/frontend/vite.config.ts +++ b/examples/showcase/frontend/vite.config.ts @@ -3,8 +3,10 @@ import { defineConfig } from "vite"; const backendUrl = process.env.VITE_SHOWCASE_BACKEND_URL || "http://127.0.0.1:8000"; +const base = process.env.VITE_BASE || "/"; export default defineConfig({ + base, plugins: [react()], server: { port: 5173, diff --git a/examples/templates_demo/README.md b/examples/templates_demo/README.md new file mode 100644 index 0000000..23f715f --- /dev/null +++ b/examples/templates_demo/README.md @@ -0,0 +1,32 @@ +# siwe-django templates demo + +A minimal Django project that uses **only** Django templates (no React, +no build step) to render a Sign-in with Ethereum page. The bundled +`siwe_django/siwe_login.html` template talks to `window.ethereum` +directly and posts to the standard `siwe-django` endpoints. + +This is the no-JS-toolchain counterpart to `examples/showcase/` and the +output the `siwe-django scaffold-templates` wizard step would produce +when run against an empty project. + +## Run + +```bash +cd examples/templates_demo +uv run python manage.py migrate +uv run python manage.py runserver 127.0.0.1:8000 +``` + +Open `http://127.0.0.1:8000/` and click **Sign in with Ethereum**. The +template uses the wallet's `personal_sign`, posts to `/auth/siwe/verify/`, +and surfaces the JSON response below the button. + +## What's wired + +- `templates_demo/settings.py` enables `siwe_django` in `INSTALLED_APPS` + and prepends `siwe_django.backend.SiweBackend`. +- `templates_demo/urls.py` mounts the SIWE endpoints under `/auth/siwe/` + and renders the bundled `siwe_django/siwe_login.html` at `/`. +- No `RPC_URLS` are configured, so smart contract wallet sigs (EIP-1271 + / EIP-6492) are not supported in this demo. Add an RPC URL to enable + them. diff --git a/examples/templates_demo/manage.py b/examples/templates_demo/manage.py new file mode 100644 index 0000000..81bed42 --- /dev/null +++ b/examples/templates_demo/manage.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +"""Django management entry point for the templates_demo example.""" + +from __future__ import annotations + +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "templates_demo.settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/examples/templates_demo/templates_demo/__init__.py b/examples/templates_demo/templates_demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/templates_demo/templates_demo/settings.py b/examples/templates_demo/templates_demo/settings.py new file mode 100644 index 0000000..4deb15b --- /dev/null +++ b/examples/templates_demo/templates_demo/settings.py @@ -0,0 +1,66 @@ +"""Minimal Django settings for the siwe-django templates demo.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.environ.get("SECRET_KEY", "templates-demo-secret") +DEBUG = True +ALLOWED_HOSTS = ["*"] + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.staticfiles", + "siwe_django", +] + +MIDDLEWARE = [ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", +] + +ROOT_URLCONF = "templates_demo.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + ], + }, + }, +] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "templates_demo.sqlite3", + }, +} + +AUTHENTICATION_BACKENDS = [ + "siwe_django.backend.SiweBackend", + "django.contrib.auth.backends.ModelBackend", +] + +USE_TZ = True +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +STATIC_URL = "/static/" + +SIWE_DJANGO = { + "DOMAIN": os.environ.get("SIWE_DOMAIN", "localhost:8000"), + "URI": os.environ.get("SIWE_URI", "http://localhost:8000/"), + "STATEMENT": "Sign in with Ethereum to the templates demo.", + "ALLOWED_CHAIN_IDS": [1, 11155111], +} diff --git a/examples/templates_demo/templates_demo/urls.py b/examples/templates_demo/templates_demo/urls.py new file mode 100644 index 0000000..7044977 --- /dev/null +++ b/examples/templates_demo/templates_demo/urls.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from django.urls import include, path +from django.views.generic import TemplateView + +urlpatterns = [ + path("auth/siwe/", include("siwe_django.urls")), + path( + "", + TemplateView.as_view( + template_name="siwe_django/siwe_login.html", + extra_context={ + "nonce_url": "/auth/siwe/nonce/", + "verify_url": "/auth/siwe/verify/", + }, + ), + name="signin", + ), +] diff --git a/pyproject.toml b/pyproject.toml index ac692a4..cf8338d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "siwe-django" -version = "0.1.0" +version = "0.2.0" description = "Reusable Django authentication for Sign-In with Ethereum." readme = "README.md" requires-python = ">=3.10" @@ -34,6 +34,18 @@ dependencies = [ [project.optional-dependencies] drf = ["djangorestframework>=3.16"] +redis = ["redis>=5.0"] +openapi = ["drf-spectacular>=0.27"] +cli = [ + "typer>=0.12", + "questionary>=2.0", + "rich>=13.7", + "libcst>=1.4", + "tomlkit>=0.13", +] + +[project.scripts] +siwe-django = "siwe_django.cli.main:app" [project.urls] Homepage = "https://github.com/Quantumlyy/siwe-django" @@ -45,10 +57,15 @@ dev = [ "build>=1.2", "djangorestframework>=3.16", "eth-account>=0.13", + "libcst>=1.4", "pytest>=8.1", "pytest-django>=4.8", "pytest-mock>=3.14", + "questionary>=2.0", + "rich>=13.7", "ruff>=0.6", + "tomlkit>=0.13", + "typer>=0.12", ] [build-system] @@ -74,3 +91,4 @@ ignore = ["RUF012"] [tool.ruff.lint.per-file-ignores] "tests/*" = ["PT009"] +"src/siwe_django/cli/main.py" = ["B008", "UP007"] diff --git a/src/siwe_django/audit.py b/src/siwe_django/audit.py new file mode 100644 index 0000000..4349623 --- /dev/null +++ b/src/siwe_django/audit.py @@ -0,0 +1,72 @@ +"""Helpers for the SIWE auth event audit log. + +Views call :func:`record_event` after each auth-relevant action with the +request context (so we can capture IP and User-Agent without coupling the +service layer to HTTP concerns). +""" + +from __future__ import annotations + +from typing import Any + +from django.http import HttpRequest + +from .models import SiweAuthEvent +from .settings import get_setting +from .webhooks import dispatch as dispatch_webhook +from .webhooks import event_payload + + +def _client_ip(request: HttpRequest) -> str | None: + forwarded = request.META.get("HTTP_X_FORWARDED_FOR") + if forwarded and get_setting("RATE_LIMIT_TRUST_X_FORWARDED_FOR"): + return forwarded.split(",", 1)[0].strip() or None + return request.META.get("REMOTE_ADDR") or None + + +def _user_agent(request: HttpRequest) -> str: + return (request.META.get("HTTP_USER_AGENT") or "")[:512] + + +def record_event( + request: HttpRequest | None, + event: str, + *, + address: str = "", + user: Any = None, + success: bool = True, + error_code: str = "", + metadata: dict[str, Any] | None = None, +) -> SiweAuthEvent | None: + """Persist an audit event. Returns the model or ``None`` when audit is off. + + Audit logging respects ``SIWE_DJANGO["AUDIT_ENABLED"]`` (default ``True``). + Disabling skips writes entirely so apps that route audit data through a + different sink (e.g. a SIEM) can avoid the DB cost. + """ + user_pk = getattr(user, "pk", None) if user is not None else None + payload = event_payload( + event, + address=address, + user_id=str(user_pk) if user_pk else None, + success=success, + error_code=error_code, + metadata=metadata, + ) + dispatch_webhook(event, payload) + + if not get_setting("AUDIT_ENABLED"): + return None + ip = _client_ip(request) if request is not None else None + ua = _user_agent(request) if request is not None else "" + user_obj = user if user is not None and getattr(user, "pk", None) else None + return SiweAuthEvent.objects.create( + event=event, + address=address or "", + user=user_obj, + ip=ip, + user_agent=ua, + success=success, + error_code=error_code or "", + metadata=metadata or {}, + ) diff --git a/src/siwe_django/cli/__init__.py b/src/siwe_django/cli/__init__.py new file mode 100644 index 0000000..16f5ea1 --- /dev/null +++ b/src/siwe_django/cli/__init__.py @@ -0,0 +1,10 @@ +"""CLI tooling for siwe-django. + +The wizard scaffolds a Django project to use ``siwe-django``: it patches +``settings.py`` and the root ``urls.py`` via libcst, drops a ready-to-use +Django template, and runs ``manage.py migrate`` so adopters can sign in with +Ethereum within a minute of ``pip install siwe-django[cli]``. + +The CLI is opt-in (lives behind the ``cli`` extra) so the runtime package +stays free of Typer / Rich / libcst dependencies. +""" diff --git a/src/siwe_django/cli/cst.py b/src/siwe_django/cli/cst.py new file mode 100644 index 0000000..e6ed70a --- /dev/null +++ b/src/siwe_django/cli/cst.py @@ -0,0 +1,223 @@ +"""libcst-based mutators for ``settings.py`` / root ``urls.py``. + +Each mutator is idempotent: running it twice leaves the file in the same +state as running it once. This keeps the wizard safe to re-run when adopters +change their minds. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +import libcst as cst +from libcst import matchers as m + + +def _string_node(value: str) -> cst.SimpleString: + return cst.SimpleString(value=f'"{value}"') + + +def _is_target_assign(stmt: cst.SimpleStatementLine, name: str) -> bool: + if not stmt.body or not isinstance(stmt.body[0], cst.Assign): + return False + targets = stmt.body[0].targets + return any( + isinstance(t.target, cst.Name) and t.target.value == name for t in targets + ) + + +def _list_already_contains(node: cst.BaseExpression, needle: str) -> bool: + return m.matches( + node, + m.List( + elements=[ + m.ZeroOrMore(), + m.Element(value=m.SimpleString(value=f'"{needle}"')), + m.ZeroOrMore(), + ] + ), + ) or m.matches( + node, + m.List( + elements=[ + m.ZeroOrMore(), + m.Element(value=m.SimpleString(value=f"'{needle}'")), + m.ZeroOrMore(), + ] + ), + ) + + +def _append_to_list( + list_node: cst.List, value: str, *, prepend: bool = False +) -> cst.List: + if _list_already_contains(list_node, value): + return list_node + new_element = cst.Element(value=_string_node(value)) + elements = list(list_node.elements) + if prepend: + elements.insert(0, new_element) + else: + elements.append(new_element) + return list_node.with_changes(elements=elements) + + +class _ListAppendTransformer(cst.CSTTransformer): + def __init__(self, name: str, values: Iterable[str], *, prepend: bool): + self.name = name + self.values = list(values) + self.prepend = prepend + self.modified = False + self.created = False + + def leave_Module( + self, original_node: cst.Module, updated_node: cst.Module + ) -> cst.Module: + new_body: list[cst.BaseStatement] = [] + seen = False + for stmt in updated_node.body: + if isinstance(stmt, cst.SimpleStatementLine) and _is_target_assign( + stmt, self.name + ): + seen = True + new_body.append(self._mutate_assign(stmt)) + else: + new_body.append(stmt) + if not seen: + new_body.append(self._build_assign()) + self.created = True + return updated_node.with_changes(body=new_body) + + def _mutate_assign( + self, stmt: cst.SimpleStatementLine + ) -> cst.SimpleStatementLine: + assign = stmt.body[0] + assert isinstance(assign, cst.Assign) + if not isinstance(assign.value, cst.List): + return stmt + list_node = assign.value + for value in self.values: + new_list = _append_to_list(list_node, value, prepend=self.prepend) + if new_list is not list_node: + self.modified = True + list_node = new_list + new_assign = assign.with_changes(value=list_node) + return stmt.with_changes(body=[new_assign]) + + def _build_assign(self) -> cst.SimpleStatementLine: + elements = [cst.Element(value=_string_node(v)) for v in self.values] + list_node = cst.List(elements=elements) + target = cst.AssignTarget(target=cst.Name(self.name)) + return cst.SimpleStatementLine( + body=[cst.Assign(targets=[target], value=list_node)] + ) + + +def add_to_list_setting( + source: str, name: str, values: Iterable[str], *, prepend: bool = False +) -> str: + """Add ``values`` to the list assigned to ``name`` (creating the list if + absent). When ``prepend`` is True new entries are inserted at the start — + use this for ``AUTHENTICATION_BACKENDS`` so SiweBackend wins over the + default ``ModelBackend``. + """ + module = cst.parse_module(source) + transformer = _ListAppendTransformer(name, values, prepend=prepend) + new_module = module.visit(transformer) + return new_module.code + + +def _has_top_level_assign(module: cst.Module, name: str) -> bool: + return any( + isinstance(stmt, cst.SimpleStatementLine) + and _is_target_assign(stmt, name) + for stmt in module.body + ) + + +def add_settings_block(source: str, block_code: str, *, name: str) -> str: + """Append ``block_code`` to ``source`` if a top-level ``name = ...`` + assignment is not already present. ``block_code`` is appended verbatim and + must contain its own assignment (e.g. ``\"SIWE_DJANGO = {...}\"``). + """ + module = cst.parse_module(source) + if _has_top_level_assign(module, name): + return module.code + addition = cst.parse_module(block_code).body + new_body = list(module.body) + list(addition) + return module.with_changes(body=new_body).code + + +def ensure_url_include(source: str, route: str, dotted_path: str) -> str: + """Ensure root ``urls.py`` mounts ``include("siwe_django.urls")`` (or + similar) at ``route``. Idempotent. + """ + if dotted_path in source and route in source: + return source + module = cst.parse_module(source) + has_include = any( + m.matches( + stmt, + m.SimpleStatementLine( + body=[ + m.ImportFrom( + module=m.Attribute() | m.Name(), + names=m.OneOf( + m.ImportStar(), + [ + m.ZeroOrMore(), + m.ImportAlias(name=m.Name("include")), + m.ZeroOrMore(), + ], + ), + ) + ] + ), + ) + for stmt in module.body + if isinstance(stmt, cst.SimpleStatementLine) + ) + new_body = list(module.body) + if not has_include: + new_body.insert( + 0, + cst.parse_statement("from django.urls import include, path"), + ) + + inserted = False + for index, stmt in enumerate(new_body): + if isinstance(stmt, cst.SimpleStatementLine) and _is_target_assign( + stmt, "urlpatterns" + ): + assign = stmt.body[0] + assert isinstance(assign, cst.Assign) + if isinstance(assign.value, cst.List): + new_element = cst.Element( + value=cst.Call( + func=cst.Name("path"), + args=[ + cst.Arg(value=_string_node(route)), + cst.Arg( + value=cst.Call( + func=cst.Name("include"), + args=[cst.Arg(value=_string_node(dotted_path))], + ) + ), + ], + ) + ) + elements = [*assign.value.elements, new_element] + new_value = assign.value.with_changes(elements=elements) + new_assign = assign.with_changes(value=new_value) + new_body[index] = stmt.with_changes(body=[new_assign]) + inserted = True + break + + if not inserted: + new_body.append( + cst.parse_statement( + f'urlpatterns = [path("{route}", include("{dotted_path}"))]' + ) + ) + + return cst.Module(body=new_body).code diff --git a/src/siwe_django/cli/doctor_cmd.py b/src/siwe_django/cli/doctor_cmd.py new file mode 100644 index 0000000..b85236b --- /dev/null +++ b/src/siwe_django/cli/doctor_cmd.py @@ -0,0 +1,137 @@ +"""``siwe-django doctor`` — diagnose an existing siwe-django installation.""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from typing import Any + +DEFAULT_ETHID_HEALTH = "https://api.ethfollow.xyz/api/v1/leaderboard/count" + + +@dataclass(frozen=True) +class Finding: + severity: str + message: str + + @property + def is_blocking(self) -> bool: + return self.severity == "error" + + +def _http_ok(url: str, *, timeout: float = 3.0) -> bool: + try: + request = urllib.request.Request( + url, headers={"User-Agent": "siwe-django-doctor"} + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status < 500 + except (urllib.error.URLError, OSError, ValueError): + return False + + +def diagnose(siwe_settings: Mapping[str, Any]) -> list[Finding]: + """Inspect a SIWE_DJANGO settings dict and return a list of findings. + + The ``Finding.severity`` values are ``"error"`` (must fix) and ``"warning"`` + (advisory). + """ + findings: list[Finding] = [] + + domain = siwe_settings.get("DOMAIN") + uri = siwe_settings.get("URI") + if not domain: + findings.append( + Finding( + "warning", + "DOMAIN is unset — falling back to request host." + " Set explicitly behind a proxy.", + ) + ) + if not uri: + findings.append( + Finding( + "warning", + "URI is unset — falling back to request root URI." + " Set explicitly to the canonical URI.", + ) + ) + + rpcs = siwe_settings.get("RPC_URLS") or {} + if rpcs: + for chain_id, url in dict(rpcs).items(): + if not _http_ok(url): + findings.append( + Finding( + "error", + f"RPC for chain {chain_id} unreachable: {url}", + ) + ) + + chain_ids = siwe_settings.get("ALLOWED_CHAIN_IDS") + if isinstance(chain_ids, Iterable) and not isinstance(chain_ids, (str, bytes)): + configured_chains = {int(c) for c in chain_ids} + rpc_chains = {int(c) for c in rpcs} + missing = configured_chains - rpc_chains + if missing: + findings.append( + Finding( + "warning", + "ALLOWED_CHAIN_IDS includes chains without an RPC URL " + f"({sorted(missing)}); contract wallets on those chains " + "cannot be verified.", + ) + ) + + if siwe_settings.get("ETHID_ENABLED"): + api_base = str( + siwe_settings.get("ETHID_API_BASE_URL") + or "https://api.ethfollow.xyz/api/v1" + ).rstrip("/") + if not _http_ok(f"{api_base}/leaderboard/count"): + findings.append( + Finding( + "error", + f"EthID API unreachable at {api_base}.", + ) + ) + + return findings + + +def to_json(findings: Iterable[Finding]) -> str: + return json.dumps( + [{"severity": f.severity, "message": f.message} for f in findings], + indent=2, + ) + + +def has_blocking(findings: Iterable[Finding]) -> bool: + return any(f.is_blocking for f in findings) + + +def settings_from_env(env: Mapping[str, str] | None = None) -> dict[str, Any]: + """Reconstruct a minimal SIWE_DJANGO mapping from environment variables. + + Used when the doctor command is invoked outside a configured Django process + (e.g. in CI before ``manage.py`` is available). + """ + env = env or os.environ + rpcs: dict[int, str] = {} + for key, value in env.items(): + if key.startswith("SIWE_RPC_") and value: + chain_name = key[len("SIWE_RPC_") :] + try: + rpcs[int(chain_name)] = value + except ValueError: + continue + return { + "DOMAIN": env.get("SIWE_DOMAIN") or "", + "URI": env.get("SIWE_URI") or "", + "RPC_URLS": rpcs, + "ETHID_ENABLED": env.get("SIWE_ETHID_ENABLED", "").lower() in {"1", "true"}, + } diff --git a/src/siwe_django/cli/init_cmd.py b/src/siwe_django/cli/init_cmd.py new file mode 100644 index 0000000..78af845 --- /dev/null +++ b/src/siwe_django/cli/init_cmd.py @@ -0,0 +1,159 @@ +"""``siwe-django init`` — patch a Django project to use siwe-django.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from string import Template + +from rich.console import Console + +from .cst import add_settings_block, add_to_list_setting +from .scaffold import patch_root_urls, write_login_template + +_SIWE_DJANGO_BLOCK = Template( + 'SIWE_DJANGO = {\n' + ' "DOMAIN": "$domain",\n' + ' "URI": "$uri",\n' + ' "STATEMENT": "Sign in with Ethereum.",\n' + ' "ALLOWED_CHAIN_IDS": [1, 11155111],\n' + '}\n' +) + + +@dataclass(frozen=True) +class InitOptions: + project_root: Path + settings_path: Path + urls_path: Path + use_drf: bool + domain: str + uri: str + scaffold_template: bool + run_migrate: bool + manage_path: Path | None = None + + +def detect_settings_path(project_root: Path) -> Path | None: + candidates = [ + path + for path in project_root.glob("*/settings.py") + if path.parent.name not in {"siwe_django", "tests"} + ] + if len(candidates) == 1: + return candidates[0] + nested = [ + path + for path in project_root.glob("*/*/settings.py") + if "site-packages" not in path.parts + ] + if len(nested) == 1: + return nested[0] + return None + + +def detect_urls_path(settings_path: Path) -> Path: + return settings_path.with_name("urls.py") + + +def detect_manage_py(project_root: Path) -> Path | None: + candidate = project_root / "manage.py" + return candidate if candidate.exists() else None + + +def patch_settings(options: InitOptions) -> list[str]: + """Apply settings.py edits. Returns a list of human-readable change notes.""" + notes: list[str] = [] + source = options.settings_path.read_text(encoding="utf-8") + original = source + + source = add_to_list_setting(source, "INSTALLED_APPS", ["siwe_django"]) + source = add_to_list_setting( + source, + "AUTHENTICATION_BACKENDS", + ["siwe_django.backend.SiweBackend"], + prepend=True, + ) + + block = _SIWE_DJANGO_BLOCK.substitute(domain=options.domain, uri=options.uri) + source = add_settings_block(source, block, name="SIWE_DJANGO") + + if source != original: + options.settings_path.write_text(source, encoding="utf-8") + notes.append(f"patched {options.settings_path}") + return notes + + +def patch_urls(options: InitOptions) -> list[str]: + notes: list[str] = [] + dotted = ( + "siwe_django.drf.urls" if options.use_drf else "siwe_django.urls" + ) + if patch_root_urls(options.urls_path, "auth/siwe/", dotted): + notes.append(f"patched {options.urls_path}") + return notes + + +def scaffold_template(options: InitOptions) -> list[str]: + if not options.scaffold_template: + return [] + written = write_login_template(options.project_root) + return [f"wrote {written}"] + + +def run_migrate(options: InitOptions) -> list[str]: + if not options.run_migrate or options.manage_path is None: + return [] + cmd = [sys.executable, str(options.manage_path), "migrate", "--noinput"] + completed = subprocess.run( + cmd, + cwd=options.project_root, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + return [ + f"migrate failed (exit {completed.returncode}):" + f"\n{completed.stderr.strip()}" + ] + return ["ran manage.py migrate"] + + +def run_init(options: InitOptions, console: Console | None = None) -> list[str]: + """Apply all init steps and return the list of change notes (also printed).""" + console = console or Console() + notes: list[str] = [] + notes.extend(patch_settings(options)) + notes.extend(patch_urls(options)) + notes.extend(scaffold_template(options)) + notes.extend(run_migrate(options)) + for note in notes: + console.print(f"[green]✓[/green] {note}") + if not notes: + console.print( + "[yellow]nothing to do — settings already include siwe_django[/yellow]" + ) + return notes + + +_RPC_ENV_RE = re.compile(r"^SIWE_RPC_([A-Z0-9_]+)=(.*)$", re.MULTILINE) + + +def upsert_env_example(env_path: Path, rpcs: dict[str, str]) -> None: + """Add ``SIWE_RPC_=...`` entries to ``.env.example`` (creating the + file if absent). Existing entries are preserved. + """ + text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" + existing = {match.group(1) for match in _RPC_ENV_RE.finditer(text)} + additions = "\n".join( + f"SIWE_RPC_{name.upper()}={url}" + for name, url in rpcs.items() + if name.upper() not in existing + ) + if additions: + suffix = "\n" if text and not text.endswith("\n") else "" + env_path.write_text(text + suffix + additions + "\n", encoding="utf-8") diff --git a/src/siwe_django/cli/main.py b/src/siwe_django/cli/main.py new file mode 100644 index 0000000..11c0a89 --- /dev/null +++ b/src/siwe_django/cli/main.py @@ -0,0 +1,195 @@ +"""Typer application exposing siwe-django wizard subcommands.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console +from rich.table import Table + +from . import doctor_cmd, init_cmd, migrate_payton + +app = typer.Typer( + add_completion=False, + no_args_is_help=True, + help="siwe-django setup wizard.", +) + + +def _abort(console: Console, message: str) -> None: + console.print(f"[red]error:[/red] {message}") + raise typer.Exit(code=1) + + +@app.command("init") +def init_command( + project: Path = typer.Option( + Path.cwd(), "--project", "-p", help="Project root containing manage.py." + ), + settings: Optional[Path] = typer.Option( + None, + "--settings", + help="Path to settings.py. Auto-detected when not provided.", + ), + drf: bool = typer.Option( + False, "--drf", help="Wire the DRF endpoints (`siwe_django.drf.urls`)." + ), + domain: str = typer.Option( + "example.com", + "--domain", + help="SIWE DOMAIN value to write into settings.", + ), + uri: str = typer.Option( + "https://example.com/", + "--uri", + help="SIWE URI value to write into settings.", + ), + no_template: bool = typer.Option( + False, + "--no-template", + help="Skip dropping the bundled siwe_login.html template.", + ), + no_migrate: bool = typer.Option( + False, + "--no-migrate", + help="Skip running manage.py migrate after patching settings.", + ), +) -> None: + """Patch settings.py + urls.py to use siwe-django.""" + console = Console() + project = project.resolve() + settings_path = settings or init_cmd.detect_settings_path(project) + if settings_path is None: + _abort( + console, + "could not auto-detect settings.py. Pass --settings explicitly.", + ) + assert settings_path is not None + settings_path = settings_path.resolve() + options = init_cmd.InitOptions( + project_root=project, + settings_path=settings_path, + urls_path=init_cmd.detect_urls_path(settings_path), + use_drf=drf, + domain=domain, + uri=uri, + scaffold_template=not no_template, + run_migrate=not no_migrate, + manage_path=init_cmd.detect_manage_py(project), + ) + init_cmd.run_init(options, console) + + +@app.command("scaffold-templates") +def scaffold_command( + project: Path = typer.Option( + Path.cwd(), "--project", "-p", help="Project root." + ), + settings: Optional[Path] = typer.Option( + None, "--settings", help="Path to settings.py for the URL include step." + ), + drf: bool = typer.Option( + False, "--drf", help="Mount the DRF urls instead of the vanilla ones." + ), +) -> None: + """Drop a working Django sign-in template + URL include into the project.""" + from .scaffold import patch_root_urls, write_login_template + + console = Console() + project = project.resolve() + written = write_login_template(project) + console.print(f"[green]✓[/green] wrote {written}") + settings_path = settings or init_cmd.detect_settings_path(project) + if settings_path is None: + console.print( + "[yellow]could not auto-detect settings.py — skipping URL include[/yellow]" + ) + return + urls_path = init_cmd.detect_urls_path(settings_path) + dotted = "siwe_django.drf.urls" if drf else "siwe_django.urls" + if patch_root_urls(urls_path, "auth/siwe/", dotted): + console.print(f"[green]✓[/green] patched {urls_path}") + + +@app.command("doctor") +def doctor_command( + json_output: bool = typer.Option( + False, "--json", help="Print findings as JSON for CI consumption." + ), +) -> None: + """Diagnose an existing siwe-django installation.""" + console = Console() + try: + import django + from django.conf import settings as django_settings + + django.setup() + config = dict(getattr(django_settings, "SIWE_DJANGO", {}) or {}) + except Exception: + config = doctor_cmd.settings_from_env() + + findings = doctor_cmd.diagnose(config) + + if json_output: + typer.echo(doctor_cmd.to_json(findings)) + raise typer.Exit(code=1 if doctor_cmd.has_blocking(findings) else 0) + + if not findings: + console.print("[green]✓[/green] no issues detected.") + return + + table = Table(title="siwe-django doctor") + table.add_column("severity") + table.add_column("message") + for finding in findings: + colour = "red" if finding.is_blocking else "yellow" + table.add_row(f"[{colour}]{finding.severity}[/{colour}]", finding.message) + console.print(table) + if doctor_cmd.has_blocking(findings): + raise typer.Exit(code=1) + + +@app.command("migrate-from-payton") +def migrate_payton_command( + project: Path = typer.Option( + Path.cwd(), "--project", "-p", help="Project root to rewrite." + ), + dry_run: bool = typer.Option( + False, "--dry-run", help="Print what would change without writing." + ), +) -> None: + """Rewrite payton/django-siwe-auth references to siwe-django.""" + console = Console() + project = project.resolve() + if dry_run: + files = [ + path + for path in project.rglob("*.py") + if not any( + part in {".venv", "venv", "node_modules"} for part in path.parts + ) + ] + affected = [] + for path in files: + text = path.read_text(encoding="utf-8") + _, count = migrate_payton.rewrite_text(text) + if count: + affected.append((path, count)) + console.print(json.dumps({"files": [str(p) for p, _ in affected]})) + return + + summary = migrate_payton.rewrite_project(project) + console.print( + f"[green]✓[/green] {summary.replacements_applied} replacement(s) " + f"across {summary.files_modified} of {summary.files_scanned} files." + ) + console.print("\nFollow-ups:") + for item in migrate_payton.POST_MIGRATION_CHECKLIST: + console.print(f" - {item}") + + +if __name__ == "__main__": # pragma: no cover + app() diff --git a/src/siwe_django/cli/migrate_payton.py b/src/siwe_django/cli/migrate_payton.py new file mode 100644 index 0000000..5ebdb24 --- /dev/null +++ b/src/siwe_django/cli/migrate_payton.py @@ -0,0 +1,91 @@ +"""``siwe-django migrate-from-payton`` — best-effort rewrite of a project that +uses ``payton/django-siwe-auth`` to use ``siwe-django`` instead. + +Scope: this only does textual rewrites (imports, settings keys, URL includes) +and prints a checklist of items the developer must verify manually (custom +``Wallet`` model migration, group manager rewrites, etc.). It does not touch +existing data — destructive migrations stay opt-in. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +# Order matters: targeted renames run before the catch-all package rename so +# `Wallet`/`Nonce` only get replaced inside contexts that are clearly the +# payton models (qualified by the package name). +REGEX_REPLACEMENTS: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r"\bsiwe_auth\.models\.Wallet\b"), "siwe_django.models.SiweWallet"), + (re.compile(r"\bsiwe_auth\.models\.Nonce\b"), "siwe_django.models.SiweNonce"), + ( + re.compile( + r"(from\s+siwe_auth\.models\s+import\s+(?:[^\n]*?,\s*)?)Wallet\b" + ), + r"\1SiweWallet", + ), + ( + re.compile( + r"(from\s+siwe_auth\.models\s+import\s+(?:[^\n]*?,\s*)?)Nonce\b" + ), + r"\1SiweNonce", + ), + (re.compile(r"\bsiwe_auth\b"), "siwe_django"), + (re.compile(r"\bCREATE_ENS_PROFILE_ON_AUTHN\b"), "ENS_ENABLED"), +) + +POST_MIGRATION_CHECKLIST = ( + "Replace any custom group managers (django-siwe-auth's `CUSTOM_GROUPS`) " + "with `TOKEN_GATES` entries — siwe-django ships ERC-20/721/1155 + EFP/ENS" + " gates out of the box.", + "If your project relies on django-siwe-auth's `Wallet` model as the user" + " model, plan a data migration into `siwe_django.SiweWallet` (linked to" + " `AUTH_USER_MODEL`).", + "Re-run `python manage.py migrate` after the rewrite.", + "Update any front-end calls to use the siwe-django endpoints " + "(`/auth/siwe/nonce/`, `/auth/siwe/verify/`, `/auth/siwe/me/`).", +) + + +@dataclass(frozen=True) +class RewriteSummary: + files_scanned: int + files_modified: int + replacements_applied: int + + +def rewrite_text(source: str) -> tuple[str, int]: + """Apply known replacements to ``source``. Returns the new text + count.""" + new_source = source + applied = 0 + for pattern, replacement in REGEX_REPLACEMENTS: + new_source, count = pattern.subn(replacement, new_source) + applied += count + return new_source, applied + + +def _iter_python_files(root: Path) -> list[Path]: + return [ + path + for path in root.rglob("*.py") + if not any(part in {".venv", "venv", "node_modules"} for part in path.parts) + ] + + +def rewrite_project(root: Path) -> RewriteSummary: + files = _iter_python_files(root) + modified = 0 + total_applied = 0 + for path in files: + original = path.read_text(encoding="utf-8") + rewritten, applied = rewrite_text(original) + if applied: + path.write_text(rewritten, encoding="utf-8") + modified += 1 + total_applied += applied + return RewriteSummary( + files_scanned=len(files), + files_modified=modified, + replacements_applied=total_applied, + ) diff --git a/src/siwe_django/cli/scaffold.py b/src/siwe_django/cli/scaffold.py new file mode 100644 index 0000000..5df52f3 --- /dev/null +++ b/src/siwe_django/cli/scaffold.py @@ -0,0 +1,49 @@ +"""Drop a working Django sign-in template + app into an existing project.""" + +from __future__ import annotations + +from importlib import resources +from pathlib import Path + +from .cst import ensure_url_include + +TEMPLATE_RELATIVE_PATH = "siwe_django/siwe_login.html" + + +def _bundled_template_text() -> str: + return ( + resources.files("siwe_django") + .joinpath("templates", "siwe_django", "siwe_login.html") + .read_text(encoding="utf-8") + ) + + +def write_login_template(target_root: Path, *, overwrite: bool = False) -> Path: + """Write the bundled sign-in template into ``target_root/templates/...``. + + Returns the path written. If the file exists and ``overwrite`` is False, + leaves the existing file alone. + """ + target = target_root / "templates" / TEMPLATE_RELATIVE_PATH + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists() and not overwrite: + return target + target.write_text(_bundled_template_text(), encoding="utf-8") + return target + + +def patch_root_urls(urls_path: Path, route: str, dotted_path: str) -> bool: + """Mount ``include(dotted_path)`` at ``route`` in the project's root + ``urls.py``. Returns True when the file was modified. + """ + if not urls_path.exists(): + urls_path.write_text( + "from django.urls import include, path\n\nurlpatterns = []\n", + encoding="utf-8", + ) + original = urls_path.read_text(encoding="utf-8") + updated = ensure_url_include(original, route, dotted_path) + if updated != original: + urls_path.write_text(updated, encoding="utf-8") + return True + return False diff --git a/src/siwe_django/drf/schema.py b/src/siwe_django/drf/schema.py new file mode 100644 index 0000000..5b3ccd5 --- /dev/null +++ b/src/siwe_django/drf/schema.py @@ -0,0 +1,43 @@ +"""Optional drf-spectacular decorators for the DRF view layer. + +The ``openapi`` extra installs drf-spectacular and unlocks full schemas. When +drf-spectacular is not present we expose passthrough decorators so importing +``siwe_django.drf.views`` does not error in the default install. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +try: # pragma: no cover - exercised only with the optional extra installed + from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema + + SPECTACULAR_INSTALLED = True +except ImportError: # pragma: no cover + + SPECTACULAR_INSTALLED = False + + def extend_schema(*_args: Any, **_kwargs: Any) -> Callable[[Callable], Callable]: + def decorator(view: Callable) -> Callable: + return view + + return decorator + + class OpenApiExample: # type: ignore[no-redef] + def __init__(self, *_args: Any, **_kwargs: Any) -> None: ... + + class OpenApiResponse: # type: ignore[no-redef] + def __init__(self, *_args: Any, **_kwargs: Any) -> None: ... + + +SIWE_TAG = "siwe" + + +__all__ = [ + "SIWE_TAG", + "SPECTACULAR_INSTALLED", + "OpenApiExample", + "OpenApiResponse", + "extend_schema", +] diff --git a/src/siwe_django/drf/urls.py b/src/siwe_django/drf/urls.py index 34f3303..1a9346a 100644 --- a/src/siwe_django/drf/urls.py +++ b/src/siwe_django/drf/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ path("nonce/", views.NonceView.as_view(), name="nonce"), path("verify/", views.VerifyView.as_view(), name="verify"), + path("reauth/", views.ReauthView.as_view(), name="reauth"), path("me/", views.MeView.as_view(), name="me"), path("logout/", views.LogoutView.as_view(), name="logout"), path("link/", views.LinkView.as_view(), name="link"), diff --git a/src/siwe_django/drf/views.py b/src/siwe_django/drf/views.py index de4fe5a..0d6baf5 100644 --- a/src/siwe_django/drf/views.py +++ b/src/siwe_django/drf/views.py @@ -8,7 +8,9 @@ from rest_framework.response import Response from rest_framework.views import APIView -from siwe_django.models import SiweWallet +from siwe_django.audit import record_event +from siwe_django.drf.schema import SIWE_TAG, extend_schema +from siwe_django.models import SiweAuthEvent, SiweWallet from siwe_django.services import ( SiweAuthError, authenticate_siwe, @@ -20,8 +22,10 @@ serialize_user, serialize_wallet, unlink_wallet, + verify_siwe_message, ) from siwe_django.settings import get_setting +from siwe_django.stepup import mark_recent_siwe from siwe_django.views import SIWE_BACKEND from .serializers import SiweVerifySerializer @@ -35,12 +39,22 @@ def _error(error: SiweAuthError) -> Response: @method_decorator(ensure_csrf_cookie, name="dispatch") +@extend_schema( + tags=[SIWE_TAG], + summary="Issue a SIWE nonce", + description=( + "Returns a single-use nonce, the expected `domain` and `uri`, and the" + " EthereumIdentityKit metadata clients should embed when preparing the" + " EIP-4361 message." + ), +) class NonceView(APIView): authentication_classes = [] permission_classes = [] def get(self, request): nonce = issue_nonce(request) + record_event(request, SiweAuthEvent.EVENT_NONCE_ISSUED) return Response( { "nonce": nonce.nonce, @@ -54,6 +68,14 @@ def get(self, request): @method_decorator(csrf_protect, name="dispatch") +@extend_schema( + tags=[SIWE_TAG], + summary="Verify a SIWE message + signature", + description=( + "Verifies the EIP-4361 message against the bound nonce, logs the user" + " in, syncs gates, and returns the user/wallet payload." + ), +) class VerifyView(APIView): authentication_classes = [] permission_classes = [] @@ -68,8 +90,21 @@ def post(self, request): request, ) except SiweAuthError as exc: + record_event( + request, + SiweAuthEvent.EVENT_VERIFY_FAILURE, + success=False, + error_code=exc.code, + ) return _error(exc) auth_login(request, result.user, backend=SIWE_BACKEND) + mark_recent_siwe(request) + record_event( + request, + SiweAuthEvent.EVENT_VERIFY_SUCCESS, + address=result.identity.address, + user=result.user, + ) return Response( { "success": True, @@ -79,6 +114,72 @@ def post(self, request): ) +@method_decorator(csrf_protect, name="dispatch") +@extend_schema( + tags=[SIWE_TAG], + summary="Step-up: re-verify SIWE for the current user", + description=( + "Re-verifies a SIWE message for the authenticated user. Updates the" + " session's `siwe_last_verified_at` so `@require_recent_siwe` can gate" + " sensitive actions." + ), +) +class ReauthView(APIView): + def post(self, request): + if not request.user.is_authenticated: + return Response( + {"success": False, "error": "not_authenticated"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + serializer = SiweVerifySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + identity = verify_siwe_message( + serializer.validated_data["message"], + serializer.validated_data["signature"], + request, + ) + except SiweAuthError as exc: + record_event( + request, + SiweAuthEvent.EVENT_VERIFY_FAILURE, + user=request.user, + success=False, + error_code=exc.code, + metadata={"stepup": True}, + ) + return _error(exc) + + linked_addresses = set( + SiweWallet.objects.filter(user=request.user).values_list( + "address", flat=True + ) + ) + if identity.address not in linked_addresses: + return Response( + { + "success": False, + "error": "wallet_not_linked", + "message": "Signed wallet is not linked to the current user.", + }, + status=status.HTTP_403_FORBIDDEN, + ) + + mark_recent_siwe(request) + record_event( + request, + SiweAuthEvent.EVENT_VERIFY_SUCCESS, + address=identity.address, + user=request.user, + metadata={"stepup": True}, + ) + return Response({"success": True, "address": identity.address}) + + +@extend_schema( + tags=[SIWE_TAG], + summary="Current SIWE session", +) class MeView(APIView): def get(self, request): if not request.user.is_authenticated: @@ -96,12 +197,18 @@ def get(self, request): ) +@extend_schema(tags=[SIWE_TAG], summary="Destroy the SIWE session") class LogoutView(APIView): def post(self, request): + user = request.user if request.user.is_authenticated else None auth_logout(request) + record_event(request, SiweAuthEvent.EVENT_LOGOUT, user=user) return Response({"success": True}) +@extend_schema( + tags=[SIWE_TAG], summary="Link an additional wallet to the current user" +) class LinkView(APIView): def post(self, request): if not request.user.is_authenticated: @@ -119,10 +226,24 @@ def post(self, request): request, ) except SiweAuthError as exc: + record_event( + request, + SiweAuthEvent.EVENT_LINK_FAILURE, + user=request.user, + success=False, + error_code=exc.code, + ) return _error(exc) + record_event( + request, + SiweAuthEvent.EVENT_LINK_SUCCESS, + address=wallet.address, + user=request.user, + ) return Response({"success": True, "wallet": serialize_wallet(wallet)}) +@extend_schema(tags=[SIWE_TAG], summary="List wallets linked to the current user") class WalletsView(APIView): def get(self, request): if not request.user.is_authenticated: @@ -141,6 +262,7 @@ def get(self, request): ) +@extend_schema(tags=[SIWE_TAG], summary="Unlink a wallet from the current user") class WalletDetailView(APIView): def delete(self, request, wallet_id: int): if not request.user.is_authenticated: @@ -152,9 +274,19 @@ def delete(self, request, wallet_id: int): unlink_wallet(request.user, wallet_id) except SiweAuthError as exc: return _error(exc) + record_event( + request, + SiweAuthEvent.EVENT_UNLINK, + user=request.user, + metadata={"wallet_id": wallet_id}, + ) return Response({"success": True}) +@extend_schema( + tags=[SIWE_TAG], + summary="Public Ethereum Identity Kit profile proxy", +) class ProfileView(APIView): authentication_classes = [] permission_classes = [] diff --git a/src/siwe_django/ethid.py b/src/siwe_django/ethid.py index c995d1b..546fcf5 100644 --- a/src/siwe_django/ethid.py +++ b/src/siwe_django/ethid.py @@ -75,6 +75,118 @@ def _fetch_profile_part(path: str, *, fresh: bool | None = None) -> dict[str, An return {} +def _fetch_list_endpoint( + path: str, + *, + list_key: str, + params: dict[str, Any] | None = None, + fresh: bool | None = None, +) -> list[dict[str, Any]]: + extra = {**({"cache": "fresh"} if _fresh_requested(fresh) else {})} + if params: + extra.update({k: str(v) for k, v in params.items() if v is not None}) + query = f"?{urlencode(extra)}" if extra else "" + request = Request( + f"{_api_base_url()}/{path.lstrip('/')}{query}", + headers={"Accept": "application/json", "User-Agent": "siwe-django"}, + ) + try: + with urlopen(request, timeout=_timeout()) as response: + if response.status >= 400: + return [] + data = json.loads(response.read().decode("utf-8")) + except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, OSError): + logger.exception("EthID list lookup failed for %s.", path) + return [] + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + if isinstance(data, dict): + items = data.get(list_key) + if isinstance(items, list): + return [item for item in items if isinstance(item, dict)] + return [] + + +def fetch_efp_stats(address_or_name: str) -> dict[str, int]: + """Return ``{followers_count, following_count}`` for an address or ENS name. + + Returns an empty dict on lookup failure so callers can decide how to handle + transient EthID outages without exception handling. + """ + encoded = quote(address_or_name, safe="") + data = _fetch_profile_part(f"users/{encoded}/stats") + return { + "followers_count": _as_int( + data.get("followers_count") or data.get("followersCount") + ), + "following_count": _as_int( + data.get("following_count") or data.get("followingCount") + ), + } + + +def fetch_efp_follower_state( + viewer: str, target: str +) -> dict[str, bool]: + """Return ``{follow, block, mute}`` for "does ``viewer`` follow ``target``".""" + viewer_q = quote(viewer, safe="") + target_q = quote(target, safe="") + data = _fetch_profile_part(f"users/{viewer_q}/follower-state/{target_q}") + return { + "follow": bool(data.get("follow")), + "block": bool(data.get("block")), + "mute": bool(data.get("mute")), + } + + +def fetch_efp_followers( + address_or_name: str, *, limit: int = 100, offset: int = 0 +) -> list[dict[str, Any]]: + encoded = quote(address_or_name, safe="") + return _fetch_list_endpoint( + f"users/{encoded}/followers", + list_key="followers", + params={"limit": limit, "offset": offset}, + ) + + +def fetch_efp_following( + address_or_name: str, *, limit: int = 100, offset: int = 0 +) -> list[dict[str, Any]]: + encoded = quote(address_or_name, safe="") + return _fetch_list_endpoint( + f"users/{encoded}/following", + list_key="following", + params={"limit": limit, "offset": offset}, + ) + + +def fetch_efp_tags( + address_or_name: str, *, source: str | None = None +) -> list[dict[str, Any]]: + """Return the tags applied to ``address_or_name``. + + ``source``, when set, filters the response to tags applied by the given + address or ENS name (commonly the relying party's "hub" account). + """ + encoded = quote(address_or_name, safe="") + tags = _fetch_list_endpoint(f"users/{encoded}/tags", list_key="tags") + if source is None: + return tags + source_lc = source.lower() + return [ + tag + for tag in tags + if str(tag.get("address") or tag.get("source") or "").lower() == source_lc + ] + + +def fetch_ens_record(address_or_name: str) -> dict[str, Any]: + """Return the EthID ENS record (primary name + avatar + records).""" + encoded = quote(address_or_name, safe="") + return _fetch_profile_part(f"users/{encoded}/ens") + + def _as_int(value: Any) -> int: try: return int(value or 0) diff --git a/src/siwe_django/gates.py b/src/siwe_django/gates.py index 9b5799c..e4428c3 100644 --- a/src/siwe_django/gates.py +++ b/src/siwe_django/gates.py @@ -9,11 +9,29 @@ from django.utils.module_loading import import_string from web3 import HTTPProvider, Web3 +from .ethid import ( + fetch_efp_follower_state, + fetch_efp_stats, + fetch_efp_tags, + fetch_ens_record, +) from .models import SiweWallet from .settings import get_setting logger = logging.getLogger(__name__) +EFP_GATE_TYPES = frozenset( + { + "efp_follower_of", + "efp_followed_by", + "efp_mutual", + "efp_min_followers", + "efp_tag", + "efp_not_blocked_by", + "ens_required", + } +) + ERC20_ABI = [ { "constant": True, @@ -87,6 +105,10 @@ def _min_balance(gate: Mapping[str, Any]) -> int: def check_gate(wallet: SiweWallet, gate: Mapping[str, Any]) -> bool: gate_type = str(gate.get("type", "")).lower() + + if gate_type in EFP_GATE_TYPES: + return _check_efp_gate(wallet, gate, gate_type) + chain_id = int(gate.get("chain_id") or wallet.chain_id) if chain_id != wallet.chain_id: return False @@ -130,6 +152,46 @@ def check_gate(wallet: SiweWallet, gate: Mapping[str, Any]) -> bool: return False +def _check_efp_gate( + wallet: SiweWallet, gate: Mapping[str, Any], gate_type: str +) -> bool: + address = wallet.address + try: + if gate_type == "efp_follower_of": + target = str(gate["target"]) + return fetch_efp_follower_state(address, target).get("follow", False) + if gate_type == "efp_followed_by": + source = str(gate["source"]) + return fetch_efp_follower_state(source, address).get("follow", False) + if gate_type == "efp_mutual": + hub = str(gate["hub"]) + user_to_hub = fetch_efp_follower_state(address, hub).get("follow", False) + hub_to_user = fetch_efp_follower_state(hub, address).get("follow", False) + return user_to_hub and hub_to_user + if gate_type == "efp_min_followers": + threshold = int(gate["threshold"]) + stats = fetch_efp_stats(address) + return stats.get("followers_count", 0) >= threshold + if gate_type == "efp_not_blocked_by": + source = str(gate["source"]) + state = fetch_efp_follower_state(source, address) + return not (state.get("block") or state.get("mute")) + if gate_type == "efp_tag": + source = str(gate["source"]) + wanted = str(gate["tag"]).lower() + tags = fetch_efp_tags(address, source=source) + return any(str(item.get("tag") or "").lower() == wanted for item in tags) + if gate_type == "ens_required": + record = fetch_ens_record(address) + return bool(record.get("name")) + except (KeyError, ValueError, TypeError): + logger.exception( + "EFP gate %r is misconfigured for %s.", gate.get("name"), wallet + ) + return False + return False + + def sync_wallet_groups(wallet: SiweWallet) -> None: for gate in get_setting("TOKEN_GATES") or []: group_name = gate.get("group") or gate.get("name") diff --git a/src/siwe_django/migrations/0004_eip4361_fields.py b/src/siwe_django/migrations/0004_eip4361_fields.py new file mode 100644 index 0000000..86e79a2 --- /dev/null +++ b/src/siwe_django/migrations/0004_eip4361_fields.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.4 on 2026-04-28 08:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("siwe_django", "0003_identity_profile"), + ] + + operations = [ + migrations.AddField( + model_name="siwenonce", + name="not_before", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="siwenonce", + name="request_id", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="siwenonce", + name="resources", + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/src/siwe_django/migrations/0005_auth_event.py b/src/siwe_django/migrations/0005_auth_event.py new file mode 100644 index 0000000..7d71c58 --- /dev/null +++ b/src/siwe_django/migrations/0005_auth_event.py @@ -0,0 +1,80 @@ +# Generated by Django 6.0.4 on 2026-04-28 08:23 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("siwe_django", "0004_eip4361_fields"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SiweAuthEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "event", + models.CharField( + choices=[ + ("nonce_issued", "Nonce issued"), + ("verify_succeeded", "Verify succeeded"), + ("verify_failed", "Verify failed"), + ("link_succeeded", "Link succeeded"), + ("link_failed", "Link failed"), + ("unlink", "Wallet unlinked"), + ("logout", "Logout"), + ], + max_length=32, + ), + ), + ("address", models.CharField(blank=True, max_length=42)), + ("ip", models.GenericIPAddressField(blank=True, null=True)), + ("user_agent", models.CharField(blank=True, max_length=512)), + ("success", models.BooleanField(default=True)), + ("error_code", models.CharField(blank=True, max_length=64)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "timestamp", + models.DateTimeField( + db_index=True, default=django.utils.timezone.now + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="siwe_auth_events", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-timestamp"], + "indexes": [ + models.Index( + fields=["address", "timestamp"], + name="siwe_django_address_804459_idx", + ), + models.Index( + fields=["event", "timestamp"], + name="siwe_django_event_5e1203_idx", + ), + ], + }, + ), + ] diff --git a/src/siwe_django/models.py b/src/siwe_django/models.py index ee4580c..e3f70bf 100644 --- a/src/siwe_django/models.py +++ b/src/siwe_django/models.py @@ -182,6 +182,9 @@ class SiweNonce(models.Model): domain = models.CharField(max_length=255, blank=True) uri = models.URLField(max_length=2048, blank=True) expires_at = models.DateTimeField() + not_before = models.DateTimeField(blank=True, null=True) + request_id = models.CharField(max_length=255, blank=True) + resources = models.JSONField(default=list, blank=True) consumed_at = models.DateTimeField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) @@ -213,3 +216,50 @@ def consume(self) -> None: def __str__(self) -> str: return self.nonce + + +class SiweAuthEvent(models.Model): + EVENT_NONCE_ISSUED = "nonce_issued" + EVENT_VERIFY_SUCCESS = "verify_succeeded" + EVENT_VERIFY_FAILURE = "verify_failed" + EVENT_LINK_SUCCESS = "link_succeeded" + EVENT_LINK_FAILURE = "link_failed" + EVENT_UNLINK = "unlink" + EVENT_LOGOUT = "logout" + + EVENT_CHOICES = [ + (EVENT_NONCE_ISSUED, "Nonce issued"), + (EVENT_VERIFY_SUCCESS, "Verify succeeded"), + (EVENT_VERIFY_FAILURE, "Verify failed"), + (EVENT_LINK_SUCCESS, "Link succeeded"), + (EVENT_LINK_FAILURE, "Link failed"), + (EVENT_UNLINK, "Wallet unlinked"), + (EVENT_LOGOUT, "Logout"), + ] + + event = models.CharField(max_length=32, choices=EVENT_CHOICES) + address = models.CharField(max_length=42, blank=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="siwe_auth_events", + ) + ip = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.CharField(max_length=512, blank=True) + success = models.BooleanField(default=True) + error_code = models.CharField(max_length=64, blank=True) + metadata = models.JSONField(default=dict, blank=True) + timestamp = models.DateTimeField(default=timezone.now, db_index=True) + + class Meta: + ordering = ["-timestamp"] + indexes = [ + models.Index(fields=["address", "timestamp"]), + models.Index(fields=["event", "timestamp"]), + ] + + def __str__(self) -> str: + suffix = self.address or "anonymous" + return f"{self.event} {suffix} @ {self.timestamp:%Y-%m-%d %H:%M:%S}" diff --git a/src/siwe_django/nonce_store.py b/src/siwe_django/nonce_store.py new file mode 100644 index 0000000..392faf7 --- /dev/null +++ b/src/siwe_django/nonce_store.py @@ -0,0 +1,261 @@ +"""Pluggable nonce storage. + +The default backend is the existing :class:`siwe_django.models.SiweNonce` +table. Apps that want sub-millisecond reads or that want nonces to live +outside the primary DB can swap in :class:`RedisNonceStore` (or anything +else implementing :class:`NonceStore`) by setting +``SIWE_DJANGO["NONCE_STORE"]``. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterable +from dataclasses import asdict, dataclass, field +from datetime import datetime +from typing import TYPE_CHECKING, Any, Protocol + +from django.utils import timezone +from django.utils.module_loading import import_string + +from .settings import get_setting + +if TYPE_CHECKING: + from .models import SiweNonce + + +@dataclass(frozen=True) +class NonceRecord: + nonce: str + expires_at: datetime + session_key: str = "" + domain: str = "" + uri: str = "" + not_before: datetime | None = None + request_id: str = "" + resources: list[str] = field(default_factory=list) + consumed_at: datetime | None = None + + @property + def is_consumed(self) -> bool: + return self.consumed_at is not None + + @property + def is_expired(self) -> bool: + return timezone.now() >= self.expires_at + + def is_usable_for_session(self, session_key: str | None) -> bool: + if self.is_consumed or self.is_expired: + return False + return not (self.session_key and self.session_key != (session_key or "")) + + +class NonceStore(Protocol): + def save( + self, + *, + nonce: str, + expires_at: datetime, + session_key: str = "", + domain: str = "", + uri: str = "", + not_before: datetime | None = None, + request_id: str = "", + resources: Iterable[str] = (), + ) -> NonceRecord: ... + + def load(self, nonce: str) -> NonceRecord | None: ... + + def consume(self, nonce: str) -> bool: ... + + +# ----------------------------------------------------------------------------- +# Django ORM backend (default) +# ----------------------------------------------------------------------------- + + +class DjangoOrmNonceStore: + """Default backend backed by the :class:`SiweNonce` model.""" + + @staticmethod + def _to_record(model: SiweNonce) -> NonceRecord: + return NonceRecord( + nonce=model.nonce, + session_key=model.session_key, + domain=model.domain, + uri=model.uri, + expires_at=model.expires_at, + not_before=model.not_before, + request_id=model.request_id, + resources=list(model.resources or []), + consumed_at=model.consumed_at, + ) + + def save( + self, + *, + nonce: str, + expires_at: datetime, + session_key: str = "", + domain: str = "", + uri: str = "", + not_before: datetime | None = None, + request_id: str = "", + resources: Iterable[str] = (), + ) -> NonceRecord: + from .models import SiweNonce + + model = SiweNonce.objects.create( + nonce=nonce, + session_key=session_key, + domain=domain, + uri=uri, + expires_at=expires_at, + not_before=not_before, + request_id=request_id, + resources=list(resources), + ) + return self._to_record(model) + + def load(self, nonce: str) -> NonceRecord | None: + from .models import SiweNonce + + model = SiweNonce.objects.filter(nonce=nonce).first() + return self._to_record(model) if model else None + + def consume(self, nonce: str) -> bool: + from .models import SiweNonce + + return ( + SiweNonce.objects.filter( + nonce=nonce, + consumed_at__isnull=True, + expires_at__gt=timezone.now(), + ).update(consumed_at=timezone.now()) + == 1 + ) + + +# ----------------------------------------------------------------------------- +# Redis backend (optional) +# ----------------------------------------------------------------------------- + + +class RedisNonceStore: + """Redis-backed store. Uses ``SET NX EX`` for save and atomic delete for + consume; replay protection is enforced because the second consumer's + delete returns 0. + + The ``client`` argument can be a ``redis.Redis`` instance or any object + matching the ``set`` / ``get`` / ``delete`` shape. If unset, the store + instantiates one from ``REDIS_URL`` in settings. + """ + + def __init__(self, client: Any | None = None, *, key_prefix: str = "siwe-nonce:"): + self.key_prefix = key_prefix + self._client = client + + @property + def client(self) -> Any: + if self._client is not None: + return self._client + try: + import redis # type: ignore[import-not-found] + except ImportError as exc: + raise RuntimeError( + "RedisNonceStore requires `redis-py`. Install via the " + "`siwe-django[redis]` extra." + ) from exc + url = get_setting("REDIS_URL") or "redis://localhost:6379/0" + self._client = redis.Redis.from_url(url, decode_responses=True) + return self._client + + def _key(self, nonce: str) -> str: + return f"{self.key_prefix}{nonce}" + + def _payload(self, record: NonceRecord) -> str: + data = asdict(record) + data["expires_at"] = record.expires_at.isoformat() + data["not_before"] = ( + record.not_before.isoformat() if record.not_before else None + ) + return json.dumps(data) + + def _from_payload(self, raw: str) -> NonceRecord: + data = json.loads(raw) + expires_at = datetime.fromisoformat(data["expires_at"]) + not_before = ( + datetime.fromisoformat(data["not_before"]) + if data.get("not_before") + else None + ) + consumed_at_raw = data.get("consumed_at") + consumed_at = ( + datetime.fromisoformat(consumed_at_raw) if consumed_at_raw else None + ) + return NonceRecord( + nonce=data["nonce"], + session_key=data.get("session_key", ""), + domain=data.get("domain", ""), + uri=data.get("uri", ""), + expires_at=expires_at, + not_before=not_before, + request_id=data.get("request_id", ""), + resources=list(data.get("resources") or []), + consumed_at=consumed_at, + ) + + def save( + self, + *, + nonce: str, + expires_at: datetime, + session_key: str = "", + domain: str = "", + uri: str = "", + not_before: datetime | None = None, + request_id: str = "", + resources: Iterable[str] = (), + ) -> NonceRecord: + record = NonceRecord( + nonce=nonce, + expires_at=expires_at, + session_key=session_key, + domain=domain, + uri=uri, + not_before=not_before, + request_id=request_id, + resources=list(resources), + ) + ttl = max(int((expires_at - timezone.now()).total_seconds()), 1) + self.client.set(self._key(nonce), self._payload(record), ex=ttl, nx=True) + return record + + def load(self, nonce: str) -> NonceRecord | None: + raw = self.client.get(self._key(nonce)) + if not raw: + return None + return self._from_payload(raw) + + def consume(self, nonce: str) -> bool: + deleted = self.client.delete(self._key(nonce)) + try: + return int(deleted) == 1 + except (TypeError, ValueError): + return bool(deleted) + + +# ----------------------------------------------------------------------------- +# Resolver +# ----------------------------------------------------------------------------- + + +def get_nonce_store() -> NonceStore: + """Resolve the configured nonce store. Caches nothing — apps that pin a + store instance should do so via Django's app config or an LRU cache. + """ + dotted = get_setting("NONCE_STORE") + if not dotted: + return DjangoOrmNonceStore() + cls_or_factory = import_string(dotted) + return cls_or_factory() diff --git a/src/siwe_django/recap.py b/src/siwe_django/recap.py new file mode 100644 index 0000000..19d40d6 --- /dev/null +++ b/src/siwe_django/recap.py @@ -0,0 +1,108 @@ +"""ERC-5573 SIWE-ReCap helpers. + +ReCap (Resource Capability) is a SIWE extension that lets a relying party +request scoped capabilities by appending a single `urn:recap:` +entry to the SIWE message's ``Resources`` list. The encoded JSON has an ``att`` +dictionary (resource URI -> ability namespace -> list of caveat objects) and an +optional ``prf`` proofs list. + +This module implements just the encoding / decoding / statement-rendering +primitives. Verifying that the signed ReCap matches what was issued is handled +in :func:`siwe_django.services.verify_siwe_message` via the ``Resources`` subset +check. +""" + +from __future__ import annotations + +import base64 +import json +from collections.abc import Iterable, Mapping +from typing import Any + +RECAP_URI_PREFIX = "urn:recap:" + +CaveatList = list[Mapping[str, Any]] +AbilityMap = Mapping[str, CaveatList] +AttMap = Mapping[str, AbilityMap] + + +def _b64url_encode(payload: bytes) -> str: + return base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii") + + +def _b64url_decode(token: str) -> bytes: + padding = "=" * (-len(token) % 4) + return base64.urlsafe_b64decode(token + padding) + + +def encode_recap(att: AttMap, prf: Iterable[str] | None = None) -> str: + """Return the ``urn:recap:`` URI for the given capabilities. + + ``att`` maps a resource URI to an ability namespace map. ``prf`` is an + optional list of proof URIs (e.g. CIDs) that delegate from another grant. + """ + if not att: + raise ValueError("ReCap att map must contain at least one resource.") + payload: dict[str, Any] = {"att": {str(k): dict(v) for k, v in att.items()}} + if prf: + payload["prf"] = [str(p) for p in prf] + encoded = _b64url_encode( + json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + ) + return f"{RECAP_URI_PREFIX}{encoded}" + + +def decode_recap(uri: str) -> dict[str, Any] | None: + """Inverse of :func:`encode_recap`. Returns ``None`` if ``uri`` is not a + valid ReCap URI rather than raising. + """ + if not isinstance(uri, str) or not uri.startswith(RECAP_URI_PREFIX): + return None + token = uri[len(RECAP_URI_PREFIX) :] + try: + decoded = _b64url_decode(token) + payload = json.loads(decoded.decode("utf-8")) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(payload, dict) or "att" not in payload: + return None + return payload + + +def find_recap_in_resources( + resources: Iterable[str] | None, +) -> dict[str, Any] | None: + """Return the decoded ReCap payload from the last entry of ``resources``. + + Per EIP-5573 a ReCap, when present, is the *last* entry of the SIWE + message's ``Resources`` list. Earlier entries are ignored. + """ + if not resources: + return None + items = list(resources) + if not items: + return None + return decode_recap(str(items[-1])) + + +def build_recap_statement(att: AttMap) -> str: + """Render a human-readable statement summarising ``att``. + + The statement is intentionally compact; relying parties that need a fully + spec-compliant rendering should compose their own and call + :func:`encode_recap` separately. + """ + if not att: + return "" + lines: list[str] = [] + for index, (resource, abilities) in enumerate(att.items(), start=1): + if not abilities: + continue + ordered = sorted(abilities.keys()) + lines.append(f"({index}) {resource}: {', '.join(ordered)}.") + if not lines: + return "" + return ( + "I further authorize the stated URI to perform the following actions" + " on my behalf:\n" + "\n".join(lines) + ) diff --git a/src/siwe_django/services.py b/src/siwe_django/services.py index 13e3329..deff2c4 100644 --- a/src/siwe_django/services.py +++ b/src/siwe_django/services.py @@ -1,6 +1,8 @@ from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass +from datetime import datetime from typing import Any from django.contrib.auth import get_user_model @@ -14,7 +16,8 @@ from .ens import resolve_ens_profile from .ethid import fetch_ethid_profile, serialize_ethid_profile from .gates import sync_wallet_groups -from .models import SiweNonce, SiweWallet, caip10_subject, checksum_address +from .models import SiweWallet, caip10_subject, checksum_address +from .nonce_store import NonceRecord, get_nonce_store from .settings import allowed_chain_ids, get_setting @@ -95,7 +98,13 @@ def request_uri(request) -> str: return request.build_absolute_uri("/") -def issue_nonce(request=None) -> SiweNonce: +def issue_nonce( + request=None, + *, + resources: Iterable[str] | None = None, + request_id: str = "", + not_before: datetime | None = None, +) -> NonceRecord: ttl = int(get_setting("NONCE_TTL_SECONDS")) session_key = _ensure_session_key(request) if request is not None else "" domain = ( @@ -104,12 +113,16 @@ def issue_nonce(request=None) -> SiweNonce: else (get_setting("DOMAIN") or "") ) uri = request_uri(request) if request is not None else (get_setting("URI") or "") - return SiweNonce.objects.create( + resources_list = [str(item) for item in resources] if resources else [] + return get_nonce_store().save( nonce=generate_nonce(), session_key=session_key or "", domain=domain, uri=uri, expires_at=timezone.now() + timezone.timedelta(seconds=ttl), + not_before=not_before, + request_id=request_id, + resources=resources_list, ) @@ -127,26 +140,49 @@ def _session_key(request) -> str: return request.session.session_key or "" -def _load_nonce(message_nonce: str, request=None) -> SiweNonce: - try: - nonce = SiweNonce.objects.get(nonce=message_nonce) - except SiweNonce.DoesNotExist as exc: - raise InvalidNonce("Invalid or expired SIWE nonce.") from exc - if not nonce.is_usable_for_session(_session_key(request)): +def _load_nonce(message_nonce: str, request=None) -> NonceRecord: + record = get_nonce_store().load(message_nonce) + if record is None: + raise InvalidNonce("Invalid or expired SIWE nonce.") + if not record.is_usable_for_session(_session_key(request)): raise InvalidNonce("Invalid or expired SIWE nonce.") - return nonce + return record -def _consume_nonce(nonce: SiweNonce) -> None: - updated = SiweNonce.objects.filter( - nonce=nonce.nonce, - consumed_at__isnull=True, - expires_at__gt=timezone.now(), - ).update(consumed_at=timezone.now()) - if updated != 1: +def _consume_nonce(nonce: NonceRecord) -> None: + if not get_nonce_store().consume(nonce.nonce): raise InvalidNonce("SIWE nonce has already been used.") +def _resources_subset(signed: list[str] | None, issued: list[str]) -> bool: + if not issued: + return True + if signed is None: + return False + issued_set = {str(item) for item in issued} + signed_set = {str(item) for item in signed} + return signed_set.issubset(issued_set) + + +def _check_optional_fields(siwe_message: SiweMessage, nonce: NonceRecord) -> None: + issued_resources = list(nonce.resources or []) + if issued_resources and not _resources_subset( + siwe_message.resources, issued_resources + ): + raise InvalidSignature("SIWE message resources are not authorized.") + if nonce.not_before is not None: + if siwe_message.not_before is None: + raise InvalidSignature("SIWE message is missing required Not Before.") + signed_not_before = siwe_message.not_before._datetime + if abs((signed_not_before - nonce.not_before).total_seconds()) > 1: + raise InvalidSignature("SIWE message Not Before does not match nonce.") + + +def _verification_timestamp() -> datetime: + skew = int(get_setting("CLOCK_SKEW_SECONDS") or 0) + return timezone.now() - timezone.timedelta(seconds=max(skew, 0)) + + def verify_siwe_message(message: str, signature: str, request=None) -> SiweIdentity: try: siwe_message = SiweMessage.from_message(message) @@ -161,6 +197,9 @@ def verify_siwe_message(message: str, signature: str, request=None) -> SiweIdent nonce = _load_nonce(str(siwe_message.nonce), request) expected_domain = nonce.domain or (request_domain(request) if request else None) expected_uri = nonce.uri or (request_uri(request) if request else None) + expected_request_id = nonce.request_id or None + + _check_optional_fields(siwe_message, nonce) try: siwe_message.verify( @@ -169,6 +208,8 @@ def verify_siwe_message(message: str, signature: str, request=None) -> SiweIdent uri=expected_uri, chain_id=chain_id, nonce=nonce.nonce, + request_id=expected_request_id, + timestamp=_verification_timestamp(), provider=_provider_for_chain(chain_id), strict=True, ) @@ -437,16 +478,23 @@ def primary_wallet_for_user(user) -> SiweWallet | None: return SiweWallet.objects.filter(user=user, is_primary=True).first() -def eth_identity_kit_nonce_payload(nonce: SiweNonce) -> dict[str, Any]: +def eth_identity_kit_nonce_payload(nonce: NonceRecord) -> dict[str, Any]: + message_params: dict[str, Any] = { + "domain": nonce.domain, + "uri": nonce.uri, + "version": "1", + "nonce": nonce.nonce, + } + if nonce.not_before is not None: + message_params["notBefore"] = nonce.not_before.isoformat() + if nonce.request_id: + message_params["requestId"] = nonce.request_id + if nonce.resources: + message_params["resources"] = list(nonce.resources) return { "statement": get_setting("STATEMENT"), "expirationTime": int(get_setting("NONCE_TTL_SECONDS")) * 1000, - "messageParams": { - "domain": nonce.domain, - "uri": nonce.uri, - "version": "1", - "nonce": nonce.nonce, - }, + "messageParams": message_params, } diff --git a/src/siwe_django/settings.py b/src/siwe_django/settings.py index ad78ca1..e97b248 100644 --- a/src/siwe_django/settings.py +++ b/src/siwe_django/settings.py @@ -10,6 +10,7 @@ "URI": None, "STATEMENT": "Sign in with Ethereum.", "NONCE_TTL_SECONDS": 300, + "CLOCK_SKEW_SECONDS": 60, "ALLOWED_CHAIN_IDS": None, "RPC_URLS": {}, "ENS_ENABLED": False, @@ -25,6 +26,11 @@ "RATE_LIMIT_TRUST_X_FORWARDED_FOR": False, "TOKEN_GATES": [], "SYNC_TOKEN_GATES_ON_LOGIN": True, + "AUDIT_ENABLED": True, + "NONCE_STORE": "siwe_django.nonce_store.DjangoOrmNonceStore", + "REDIS_URL": None, + "WEBHOOKS": [], + "WEBHOOK_DISPATCHER": None, } diff --git a/src/siwe_django/stepup.py b/src/siwe_django/stepup.py new file mode 100644 index 0000000..471456f --- /dev/null +++ b/src/siwe_django/stepup.py @@ -0,0 +1,71 @@ +"""Step-up authentication: re-verify a SIWE signature for sensitive actions. + +The standard sign-in flow leaves a long-lived Django session. Some endpoints +(transfer funds, rotate API keys, link a wallet, …) want a stronger guarantee +that the user has *recently* signed a fresh SIWE message. This module adds: + +- ``mark_recent_siwe(request)`` — call after a successful verify to stamp the + session with the verification time. +- ``has_recent_siwe(request, seconds)`` — predicate for views. +- ``require_recent_siwe(seconds)`` — decorator that returns 403 when the + session is missing a recent verification. +""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta, timezone +from functools import wraps + +from django.http import HttpRequest, JsonResponse + +SESSION_KEY = "siwe_last_verified_at" + + +def mark_recent_siwe(request: HttpRequest) -> None: + """Stamp the current Django session with the verification timestamp.""" + request.session[SESSION_KEY] = datetime.now(tz=timezone.utc).isoformat() + + +def last_verified_at(request: HttpRequest) -> datetime | None: + raw = request.session.get(SESSION_KEY) + if not raw: + return None + try: + return datetime.fromisoformat(raw) + except (TypeError, ValueError): + return None + + +def has_recent_siwe(request: HttpRequest, seconds: int) -> bool: + when = last_verified_at(request) + if when is None: + return False + return datetime.now(tz=timezone.utc) - when <= timedelta(seconds=seconds) + + +def require_recent_siwe(seconds: int = 300) -> Callable: + """Wrap a view to require a SIWE verify within the last ``seconds``. + + Returns 403 with ``error: "stepup_required"`` when the session is stale. + """ + + def decorator(view: Callable) -> Callable: + @wraps(view) + def wrapped(request: HttpRequest, *args, **kwargs): + if not has_recent_siwe(request, seconds): + return JsonResponse( + { + "success": False, + "error": "stepup_required", + "message": ( + "This action requires a recent SIWE verification." + ), + }, + status=403, + ) + return view(request, *args, **kwargs) + + return wrapped + + return decorator diff --git a/src/siwe_django/templates/siwe_django/siwe_login.html b/src/siwe_django/templates/siwe_django/siwe_login.html new file mode 100644 index 0000000..194416e --- /dev/null +++ b/src/siwe_django/templates/siwe_django/siwe_login.html @@ -0,0 +1,113 @@ +{% load static %} + + + + + Sign in with Ethereum + + + + +

Sign in with Ethereum

+

Click the button to request a nonce, sign it with your wallet, and verify + the signature with the server.

+ +

+ + + + + diff --git a/src/siwe_django/urls.py b/src/siwe_django/urls.py index 28c88d4..a19a8ba 100644 --- a/src/siwe_django/urls.py +++ b/src/siwe_django/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ path("nonce/", views.nonce, name="nonce"), path("verify/", views.verify, name="verify"), + path("reauth/", views.reauth, name="reauth"), path("me/", views.me, name="me"), path("logout/", views.logout, name="logout"), path("link/", views.link, name="link"), diff --git a/src/siwe_django/views.py b/src/siwe_django/views.py index 9751939..5abc1be 100644 --- a/src/siwe_django/views.py +++ b/src/siwe_django/views.py @@ -11,7 +11,8 @@ from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie from django.views.decorators.http import require_http_methods -from .models import SiweWallet +from .audit import record_event +from .models import SiweAuthEvent, SiweWallet from .services import ( SiweAuthError, authenticate_siwe, @@ -23,8 +24,10 @@ serialize_user, serialize_wallet, unlink_wallet, + verify_siwe_message, ) from .settings import get_setting +from .stepup import mark_recent_siwe SIWE_BACKEND = "siwe_django.backend.SiweBackend" @@ -98,6 +101,7 @@ def wrapped(request: HttpRequest, *args, **kwargs): @require_http_methods(["GET"]) def nonce(request: HttpRequest) -> JsonResponse: nonce_obj = issue_nonce(request) + record_event(request, SiweAuthEvent.EVENT_NONCE_ISSUED) return JsonResponse( { "nonce": nonce_obj.nonce, @@ -120,9 +124,22 @@ def verify(request: HttpRequest) -> JsonResponse: body.get("message", ""), body.get("signature", ""), request ) except SiweAuthError as exc: + record_event( + request, + SiweAuthEvent.EVENT_VERIFY_FAILURE, + success=False, + error_code=exc.code, + ) return _error_response(exc) auth_login(request, result.user, backend=SIWE_BACKEND) + mark_recent_siwe(request) + record_event( + request, + SiweAuthEvent.EVENT_VERIFY_SUCCESS, + address=result.identity.address, + user=result.user, + ) return JsonResponse( { "success": True, @@ -132,6 +149,58 @@ def verify(request: HttpRequest) -> JsonResponse: ) +@csrf_protect +@rate_limit("verify") +@require_http_methods(["POST"]) +def reauth(request: HttpRequest) -> JsonResponse: + """Re-verify a SIWE signature for the currently authenticated session.""" + if not request.user.is_authenticated: + return JsonResponse( + {"success": False, "error": "not_authenticated"}, + status=401, + ) + try: + body = _json_body(request) + identity = verify_siwe_message( + body.get("message", ""), body.get("signature", ""), request + ) + except SiweAuthError as exc: + record_event( + request, + SiweAuthEvent.EVENT_VERIFY_FAILURE, + user=request.user, + success=False, + error_code=exc.code, + metadata={"stepup": True}, + ) + return _error_response(exc) + + user_wallet_addresses = set( + SiweWallet.objects.filter(user=request.user).values_list( + "address", flat=True + ) + ) + if identity.address not in user_wallet_addresses: + return JsonResponse( + { + "success": False, + "error": "wallet_not_linked", + "message": "Signed wallet is not linked to the current user.", + }, + status=403, + ) + + mark_recent_siwe(request) + record_event( + request, + SiweAuthEvent.EVENT_VERIFY_SUCCESS, + address=identity.address, + user=request.user, + metadata={"stepup": True}, + ) + return JsonResponse({"success": True, "address": identity.address}) + + @require_http_methods(["GET"]) def me(request: HttpRequest) -> JsonResponse: if not request.user.is_authenticated: @@ -153,7 +222,13 @@ def me(request: HttpRequest) -> JsonResponse: @rate_limit("logout") @require_http_methods(["POST"]) def logout(request: HttpRequest) -> JsonResponse: + user = request.user if request.user.is_authenticated else None auth_logout(request) + record_event( + request, + SiweAuthEvent.EVENT_LOGOUT, + user=user, + ) return JsonResponse({"success": True}) @@ -175,7 +250,20 @@ def link(request: HttpRequest) -> JsonResponse: request, ) except SiweAuthError as exc: + record_event( + request, + SiweAuthEvent.EVENT_LINK_FAILURE, + user=request.user, + success=False, + error_code=exc.code, + ) return _error_response(exc) + record_event( + request, + SiweAuthEvent.EVENT_LINK_SUCCESS, + address=wallet.address, + user=request.user, + ) return JsonResponse({"success": True, "wallet": serialize_wallet(wallet)}) @@ -220,4 +308,10 @@ def wallet_detail(request: HttpRequest, wallet_id: int) -> HttpResponse: unlink_wallet(request.user, wallet_id) except SiweAuthError as exc: return _error_response(exc) + record_event( + request, + SiweAuthEvent.EVENT_UNLINK, + user=request.user, + metadata={"wallet_id": wallet_id}, + ) return JsonResponse({"success": True}) diff --git a/src/siwe_django/webhooks.py b/src/siwe_django/webhooks.py new file mode 100644 index 0000000..ab46269 --- /dev/null +++ b/src/siwe_django/webhooks.py @@ -0,0 +1,127 @@ +"""HMAC-signed webhooks fired from audit events. + +Apps subscribe via ``SIWE_DJANGO["WEBHOOKS"]`` — a list of dicts shaped:: + + { + "event": "verify_succeeded", + "url": "https://hooks.example.com/siwe", + "secret": "...", + } + +``event`` may be ``"*"`` to match every event. The body is the JSON payload +returned by :func:`event_payload`. The signature header is:: + + X-Siwe-Signature: sha256= + +where ``hex`` is ``hmac.new(secret, body, sha256).hexdigest()``. + +Dispatch is synchronous and best-effort: a failing webhook logs the error +but never blocks the auth flow. Apps that want retries / async dispatch +should plug in Celery via the ``WEBHOOK_DISPATCHER`` setting. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +from collections.abc import Iterable, Mapping +from typing import Any +from urllib import request as urlrequest +from urllib.error import HTTPError, URLError + +from django.utils.module_loading import import_string + +from .settings import get_setting + +logger = logging.getLogger(__name__) + +DEFAULT_TIMEOUT = 3.0 +SIGNATURE_HEADER = "X-Siwe-Signature" + + +def event_payload( + event: str, + *, + address: str = "", + user_id: str | None = None, + success: bool = True, + error_code: str = "", + metadata: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Canonical JSON shape we send to webhook subscribers.""" + return { + "event": event, + "address": address, + "user_id": user_id, + "success": success, + "error_code": error_code, + "metadata": dict(metadata or {}), + } + + +def sign_payload(secret: str, body: bytes) -> str: + digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + return f"sha256={digest}" + + +def matching_subscriptions(event: str) -> list[dict[str, Any]]: + configured: Iterable[Mapping[str, Any]] = get_setting("WEBHOOKS") or [] + matched: list[dict[str, Any]] = [] + for subscription in configured: + sub_event = str(subscription.get("event") or "*") + if sub_event in {"*", event}: + matched.append(dict(subscription)) + return matched + + +def deliver(subscription: Mapping[str, Any], payload: Mapping[str, Any]) -> bool: + url = str(subscription.get("url") or "") + secret = str(subscription.get("secret") or "") + if not url or not secret: + logger.warning("Skipping webhook with missing url/secret.") + return False + try: + body = json.dumps( + payload, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + except (TypeError, ValueError): + logger.exception("Webhook payload for %s is not JSON-serializable.", url) + return False + headers = { + "Content-Type": "application/json", + "User-Agent": "siwe-django-webhook", + SIGNATURE_HEADER: sign_payload(secret, body), + } + timeout = float(subscription.get("timeout") or DEFAULT_TIMEOUT) + request = urlrequest.Request(url, data=body, headers=headers, method="POST") + try: + with urlrequest.urlopen(request, timeout=timeout) as response: + return response.status < 400 + except (HTTPError, URLError, OSError, TimeoutError): + logger.exception("Webhook delivery to %s failed.", url) + return False + + +def dispatch(event: str, payload: Mapping[str, Any]) -> int: + """Deliver ``payload`` to every subscriber matching ``event``. + + Returns the number of successful deliveries. Failures are swallowed — + callers must not depend on completion for correctness. + """ + subscriptions = matching_subscriptions(event) + if not subscriptions: + return 0 + + dispatcher_path = get_setting("WEBHOOK_DISPATCHER") + if dispatcher_path: + dispatcher = import_string(dispatcher_path) + dispatcher(event, dict(payload), subscriptions) + return len(subscriptions) + + delivered = 0 + for subscription in subscriptions: + if deliver(subscription, payload): + delivered += 1 + return delivered diff --git a/tests/helpers.py b/tests/helpers.py index 1790c73..1be19b4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -23,7 +23,11 @@ def build_message( domain: str = "testserver", uri: str = "http://testserver/", chain_id: int = 1, + issued_at: str | None = None, expiration_time: str | None = None, + not_before: str | None = None, + request_id: str | None = None, + resources: list[str] | None = None, ) -> str: message = SiweMessage( domain=domain, @@ -32,8 +36,11 @@ def build_message( version="1", chain_id=chain_id, nonce=nonce, - issued_at=iso_now(), + issued_at=issued_at or iso_now(), expiration_time=expiration_time, + not_before=not_before, + request_id=request_id, + resources=resources, ) return message.prepare_message() diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..7052a31 --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import pytest +from django.test import RequestFactory, override_settings + +from siwe_django.audit import record_event +from siwe_django.models import SiweAuthEvent + +from .helpers import post_json, signed_payload + + +def _request(*, ip: str = "203.0.113.1", ua: str = "test-ua/1.0"): + factory = RequestFactory() + return factory.get("/", REMOTE_ADDR=ip, HTTP_USER_AGENT=ua) + + +@pytest.mark.django_db +def test_record_event_persists_request_metadata(): + request = _request() + + event = record_event( + request, + SiweAuthEvent.EVENT_NONCE_ISSUED, + metadata={"key": "value"}, + ) + + assert event is not None + assert event.event == SiweAuthEvent.EVENT_NONCE_ISSUED + assert event.ip == "203.0.113.1" + assert event.user_agent == "test-ua/1.0" + assert event.success is True + assert event.metadata == {"key": "value"} + + +@pytest.mark.django_db +@override_settings( + SIWE_DJANGO={ + "DOMAIN": "testserver", + "URI": "http://testserver/", + "AUDIT_ENABLED": False, + } +) +def test_record_event_skipped_when_audit_disabled(): + assert record_event(_request(), SiweAuthEvent.EVENT_NONCE_ISSUED) is None + assert SiweAuthEvent.objects.count() == 0 + + +@pytest.mark.django_db +@override_settings( + SIWE_DJANGO={ + "DOMAIN": "testserver", + "URI": "http://testserver/", + "RATE_LIMIT_TRUST_X_FORWARDED_FOR": True, + } +) +def test_record_event_uses_x_forwarded_for_when_trusted(): + factory = RequestFactory() + request = factory.get( + "/", + REMOTE_ADDR="10.0.0.1", + HTTP_X_FORWARDED_FOR="198.51.100.7, 10.0.0.1", + ) + + event = record_event(request, SiweAuthEvent.EVENT_NONCE_ISSUED) + + assert event is not None + assert event.ip == "198.51.100.7" + + +@pytest.mark.django_db +def test_nonce_endpoint_records_event(client): + response = client.get("/siwe/nonce/") + + assert response.status_code == 200 + events = list(SiweAuthEvent.objects.all()) + assert len(events) == 1 + assert events[0].event == SiweAuthEvent.EVENT_NONCE_ISSUED + + +@pytest.mark.django_db +def test_verify_success_creates_audit_event(client, django_user_model): + payload = signed_payload(client) + response = post_json( + client, + "/siwe/verify/", + {"message": payload["message"], "signature": payload["signature"]}, + ) + + assert response.status_code == 200 + success_events = SiweAuthEvent.objects.filter( + event=SiweAuthEvent.EVENT_VERIFY_SUCCESS + ) + assert success_events.count() == 1 + event = success_events.get() + assert event.address == payload["account"].address + assert event.user is not None + assert event.success is True + + +@pytest.mark.django_db +def test_verify_failure_creates_audit_event(client): + response = post_json( + client, + "/siwe/verify/", + {"message": "garbage", "signature": "0x00"}, + ) + + assert response.status_code != 200 + failure = SiweAuthEvent.objects.filter( + event=SiweAuthEvent.EVENT_VERIFY_FAILURE + ).get() + assert failure.success is False + assert failure.error_code # any non-empty error code + + +@pytest.mark.django_db +def test_logout_records_event(client): + payload = signed_payload(client) + post_json( + client, + "/siwe/verify/", + {"message": payload["message"], "signature": payload["signature"]}, + ) + SiweAuthEvent.objects.all().delete() + + response = client.post( + "/siwe/logout/", content_type="application/json", data="{}" + ) + + assert response.status_code == 200 + assert SiweAuthEvent.objects.filter(event=SiweAuthEvent.EVENT_LOGOUT).exists() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8d44549 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from siwe_django.cli import doctor_cmd, migrate_payton +from siwe_django.cli.cst import ( + add_settings_block, + add_to_list_setting, + ensure_url_include, +) +from siwe_django.cli.main import app +from siwe_django.cli.scaffold import write_login_template + +runner = CliRunner() + + +def _settings_skeleton() -> str: + return ( + "INSTALLED_APPS = [\n" + ' "django.contrib.auth",\n' + ' "django.contrib.sessions",\n' + "]\n\n" + "AUTHENTICATION_BACKENDS = [\n" + ' "django.contrib.auth.backends.ModelBackend",\n' + "]\n" + ) + + +def _urls_skeleton() -> str: + return "from django.urls import path\n\nurlpatterns = []\n" + + +# ----------------------------------------------------------------------------- +# CST mutators +# ----------------------------------------------------------------------------- + + +def test_add_to_list_appends_when_missing(): + source = _settings_skeleton() + + result = add_to_list_setting(source, "INSTALLED_APPS", ["siwe_django"]) + + assert '"siwe_django"' in result + assert result.count('"siwe_django"') == 1 + + +def test_add_to_list_is_idempotent(): + source = add_to_list_setting( + _settings_skeleton(), "INSTALLED_APPS", ["siwe_django"] + ) + again = add_to_list_setting(source, "INSTALLED_APPS", ["siwe_django"]) + + assert again == source + + +def test_add_to_list_prepends_authentication_backends(): + source = _settings_skeleton() + + result = add_to_list_setting( + source, + "AUTHENTICATION_BACKENDS", + ["siwe_django.backend.SiweBackend"], + prepend=True, + ) + + assert ( + result.index("siwe_django.backend.SiweBackend") + < result.index("django.contrib.auth.backends.ModelBackend") + ) + + +def test_add_to_list_creates_when_absent(): + source = "X = 1\n" + + result = add_to_list_setting(source, "INSTALLED_APPS", ["siwe_django"]) + + assert "INSTALLED_APPS" in result + assert '"siwe_django"' in result + + +def test_add_settings_block_idempotent(): + block = 'SIWE_DJANGO = {"DOMAIN": "example.com"}\n' + source = _settings_skeleton() + + once = add_settings_block(source, block, name="SIWE_DJANGO") + twice = add_settings_block(once, block, name="SIWE_DJANGO") + + assert once.count("SIWE_DJANGO") == 1 + assert once == twice + + +def test_ensure_url_include_appends_to_existing_urlpatterns(): + source = _urls_skeleton() + + result = ensure_url_include(source, "auth/siwe/", "siwe_django.urls") + + assert "include" in result + assert "siwe_django.urls" in result + assert "auth/siwe/" in result + + +def test_ensure_url_include_idempotent(): + once = ensure_url_include(_urls_skeleton(), "auth/siwe/", "siwe_django.urls") + twice = ensure_url_include(once, "auth/siwe/", "siwe_django.urls") + + assert once == twice + + +# ----------------------------------------------------------------------------- +# scaffold +# ----------------------------------------------------------------------------- + + +def test_write_login_template_drops_file(tmp_path: Path): + written = write_login_template(tmp_path) + + assert written.exists() + text = written.read_text(encoding="utf-8") + assert "Sign in with Ethereum" in text + assert "personal_sign" in text + + +def test_write_login_template_skips_when_present(tmp_path: Path): + target = tmp_path / "templates" / "siwe_django" / "siwe_login.html" + target.parent.mkdir(parents=True) + target.write_text("custom", encoding="utf-8") + + write_login_template(tmp_path) + + assert target.read_text(encoding="utf-8") == "custom" + + +# ----------------------------------------------------------------------------- +# init command (typer) +# ----------------------------------------------------------------------------- + + +@pytest.fixture +def fake_django_project(tmp_path: Path) -> Path: + inner = tmp_path / "myproj" + inner.mkdir() + (inner / "settings.py").write_text(_settings_skeleton(), encoding="utf-8") + (inner / "urls.py").write_text(_urls_skeleton(), encoding="utf-8") + return tmp_path + + +def test_init_command_patches_settings_and_urls(fake_django_project: Path): + result = runner.invoke( + app, + [ + "init", + "--project", + str(fake_django_project), + "--no-template", + "--no-migrate", + ], + ) + + assert result.exit_code == 0, result.output + settings_text = (fake_django_project / "myproj" / "settings.py").read_text() + urls_text = (fake_django_project / "myproj" / "urls.py").read_text() + assert "siwe_django" in settings_text + assert "siwe_django.backend.SiweBackend" in settings_text + assert "SIWE_DJANGO" in settings_text + assert "siwe_django.urls" in urls_text + + +def test_init_command_reports_no_op_when_already_configured( + fake_django_project: Path, +): + runner.invoke( + app, + [ + "init", + "--project", + str(fake_django_project), + "--no-template", + "--no-migrate", + ], + ) + second = runner.invoke( + app, + [ + "init", + "--project", + str(fake_django_project), + "--no-template", + "--no-migrate", + ], + ) + + assert second.exit_code == 0 + assert "nothing to do" in second.output.lower() + + +def test_init_command_drf_uses_drf_urls(fake_django_project: Path): + result = runner.invoke( + app, + [ + "init", + "--project", + str(fake_django_project), + "--drf", + "--no-template", + "--no-migrate", + ], + ) + + assert result.exit_code == 0, result.output + urls_text = (fake_django_project / "myproj" / "urls.py").read_text() + assert "siwe_django.drf.urls" in urls_text + + +def test_init_command_writes_template_by_default(fake_django_project: Path): + runner.invoke( + app, + ["init", "--project", str(fake_django_project), "--no-migrate"], + ) + + template = ( + fake_django_project / "templates" / "siwe_django" / "siwe_login.html" + ) + assert template.exists() + + +def test_init_command_aborts_when_settings_missing(tmp_path: Path): + result = runner.invoke( + app, + ["init", "--project", str(tmp_path), "--no-template", "--no-migrate"], + ) + + assert result.exit_code != 0 + assert "settings.py" in result.output + + +# ----------------------------------------------------------------------------- +# scaffold-templates command +# ----------------------------------------------------------------------------- + + +def test_scaffold_templates_command(fake_django_project: Path): + result = runner.invoke( + app, + ["scaffold-templates", "--project", str(fake_django_project)], + ) + + assert result.exit_code == 0, result.output + template = ( + fake_django_project / "templates" / "siwe_django" / "siwe_login.html" + ) + assert template.exists() + urls_text = (fake_django_project / "myproj" / "urls.py").read_text() + assert "siwe_django.urls" in urls_text + + +# ----------------------------------------------------------------------------- +# doctor command +# ----------------------------------------------------------------------------- + + +def test_doctor_diagnose_flags_missing_domain_and_uri(): + findings = doctor_cmd.diagnose({}) + + severities = {f.severity for f in findings} + messages = " ".join(f.message for f in findings) + assert "warning" in severities + assert "DOMAIN" in messages + assert "URI" in messages + + +def test_doctor_diagnose_clean_returns_no_findings(): + findings = doctor_cmd.diagnose( + { + "DOMAIN": "example.com", + "URI": "https://example.com/", + } + ) + + assert findings == [] + + +def test_doctor_diagnose_flags_chain_without_rpc(): + findings = doctor_cmd.diagnose( + { + "DOMAIN": "example.com", + "URI": "https://example.com/", + "ALLOWED_CHAIN_IDS": [1, 137], + "RPC_URLS": {1: "https://example.invalid"}, + } + ) + + messages = "\n".join(f.message for f in findings) + assert "137" in messages or "without an RPC" in messages + + +def test_doctor_settings_from_env_parses_rpcs(): + config = doctor_cmd.settings_from_env( + { + "SIWE_RPC_1": "https://mainnet.example", + "SIWE_RPC_137": "https://polygon.example", + "SIWE_DOMAIN": "example.com", + "SIWE_ETHID_ENABLED": "1", + } + ) + + assert config["RPC_URLS"] == { + 1: "https://mainnet.example", + 137: "https://polygon.example", + } + assert config["DOMAIN"] == "example.com" + assert config["ETHID_ENABLED"] is True + + +# ----------------------------------------------------------------------------- +# migrate-from-payton +# ----------------------------------------------------------------------------- + + +def test_migrate_payton_rewrite_text_replaces_known_paths(): + source = ( + "from siwe_auth.backends import SiweBackend\n" + "from siwe_auth.models import Wallet\n" + "INSTALLED_APPS = ['siwe_auth']\n" + ) + + rewritten, applied = migrate_payton.rewrite_text(source) + + assert applied >= 2 + assert "siwe_django" in rewritten + assert "SiweWallet" in rewritten + assert "siwe_auth" not in rewritten + + +def test_migrate_payton_rewrite_project(tmp_path: Path): + target = tmp_path / "app.py" + target.write_text( + "from siwe_auth.backends import SiweBackend\n", encoding="utf-8" + ) + + summary = migrate_payton.rewrite_project(tmp_path) + + assert summary.files_modified == 1 + assert summary.replacements_applied >= 1 + assert "siwe_django" in target.read_text(encoding="utf-8") + + +def test_migrate_payton_command_dry_run(tmp_path: Path): + target = tmp_path / "app.py" + target.write_text( + "from siwe_auth.backends import SiweBackend\n", encoding="utf-8" + ) + + result = runner.invoke( + app, ["migrate-from-payton", "--project", str(target.parent), "--dry-run"] + ) + + assert result.exit_code == 0, result.output + assert "app.py" in result.output + assert "siwe_auth" in target.read_text(encoding="utf-8") diff --git a/tests/test_drf_schema.py b/tests/test_drf_schema.py new file mode 100644 index 0000000..55f713a --- /dev/null +++ b/tests/test_drf_schema.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from siwe_django.drf import schema + + +def test_extend_schema_returns_passthrough_when_spectacular_missing(): + if schema.SPECTACULAR_INSTALLED: + # When drf-spectacular is installed the decorator returns a real + # spectacular wrapper. The passthrough behaviour we want to assert + # only matters in the optional-extra path; nothing to test here. + return + + @schema.extend_schema(tags=[schema.SIWE_TAG], summary="x") + def view(): + return "ok" + + assert view() == "ok" + + +def test_drf_views_import_without_spectacular(): + from siwe_django.drf import views as drf_views + + assert drf_views.NonceView is not None + assert drf_views.VerifyView is not None + assert drf_views.ReauthView is not None diff --git a/tests/test_efp_gates.py b/tests/test_efp_gates.py new file mode 100644 index 0000000..2edb216 --- /dev/null +++ b/tests/test_efp_gates.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import pytest +from django.contrib.auth.models import Group +from django.test import override_settings +from eth_account import Account + +from siwe_django.gates import check_gate, sync_wallet_groups +from siwe_django.models import SiweWallet, caip10_subject + + +def _make_wallet(django_user_model): + account = Account.create() + user = django_user_model.objects.create_user( + username=f"u_{account.address[2:8]}" + ) + return SiweWallet.objects.create( + user=user, + address=account.address, + chain_id=1, + caip10=caip10_subject(1, account.address), + ) + + +@pytest.mark.django_db +def test_efp_follower_of_pass(mocker, django_user_model): + wallet = _make_wallet(django_user_model) + mocker.patch( + "siwe_django.gates.fetch_efp_follower_state", + return_value={"follow": True, "block": False, "mute": False}, + ) + assert check_gate( + wallet, {"type": "efp_follower_of", "target": "hub.eth"} + ) + + +@pytest.mark.django_db +def test_efp_follower_of_fail(mocker, django_user_model): + wallet = _make_wallet(django_user_model) + mocker.patch( + "siwe_django.gates.fetch_efp_follower_state", + return_value={"follow": False}, + ) + assert not check_gate( + wallet, {"type": "efp_follower_of", "target": "hub.eth"} + ) + + +@pytest.mark.django_db +def test_efp_followed_by_calls_with_source_first(mocker, django_user_model): + wallet = _make_wallet(django_user_model) + spy = mocker.patch( + "siwe_django.gates.fetch_efp_follower_state", + return_value={"follow": True}, + ) + + assert check_gate( + wallet, {"type": "efp_followed_by", "source": "hub.eth"} + ) + + spy.assert_called_once_with("hub.eth", wallet.address) + + +@pytest.mark.django_db +def test_efp_mutual_requires_both_directions(mocker, django_user_model): + wallet = _make_wallet(django_user_model) + side = iter([{"follow": True}, {"follow": False}]) + mocker.patch( + "siwe_django.gates.fetch_efp_follower_state", + side_effect=lambda *args, **kwargs: next(side), + ) + + assert not check_gate(wallet, {"type": "efp_mutual", "hub": "hub.eth"}) + + +@pytest.mark.django_db +def test_efp_min_followers(mocker, django_user_model): + wallet = _make_wallet(django_user_model) + mocker.patch( + "siwe_django.gates.fetch_efp_stats", + return_value={"followers_count": 50, "following_count": 1}, + ) + + assert check_gate( + wallet, {"type": "efp_min_followers", "threshold": 25} + ) + assert not check_gate( + wallet, {"type": "efp_min_followers", "threshold": 100} + ) + + +@pytest.mark.django_db +def test_efp_not_blocked_by(mocker, django_user_model): + wallet = _make_wallet(django_user_model) + state = mocker.patch("siwe_django.gates.fetch_efp_follower_state") + + state.return_value = {"follow": False, "block": False, "mute": False} + assert check_gate( + wallet, {"type": "efp_not_blocked_by", "source": "hub.eth"} + ) + + state.return_value = {"follow": False, "block": True, "mute": False} + assert not check_gate( + wallet, {"type": "efp_not_blocked_by", "source": "hub.eth"} + ) + + state.return_value = {"follow": False, "block": False, "mute": True} + assert not check_gate( + wallet, {"type": "efp_not_blocked_by", "source": "hub.eth"} + ) + + +@pytest.mark.django_db +def test_efp_tag(mocker, django_user_model): + wallet = _make_wallet(django_user_model) + mocker.patch( + "siwe_django.gates.fetch_efp_tags", + return_value=[{"tag": "vip", "address": "0xHUB"}], + ) + + assert check_gate( + wallet, {"type": "efp_tag", "source": "0xhub", "tag": "VIP"} + ) + + +@pytest.mark.django_db +def test_ens_required(mocker, django_user_model): + wallet = _make_wallet(django_user_model) + record = mocker.patch("siwe_django.gates.fetch_ens_record") + + record.return_value = {"name": "alice.eth"} + assert check_gate(wallet, {"type": "ens_required"}) + + record.return_value = {"name": ""} + assert not check_gate(wallet, {"type": "ens_required"}) + + +@pytest.mark.django_db +def test_efp_gate_misconfigured_returns_false(django_user_model): + wallet = _make_wallet(django_user_model) + assert not check_gate(wallet, {"type": "efp_follower_of"}) + + +@pytest.mark.django_db +@override_settings( + SIWE_DJANGO={ + "DOMAIN": "testserver", + "URI": "http://testserver/", + "TOKEN_GATES": [ + { + "type": "efp_min_followers", + "threshold": 10, + "group": "popular", + } + ], + } +) +def test_sync_wallet_groups_adds_group_when_efp_gate_passes( + mocker, django_user_model +): + wallet = _make_wallet(django_user_model) + mocker.patch( + "siwe_django.gates.fetch_efp_stats", + return_value={"followers_count": 100, "following_count": 1}, + ) + + sync_wallet_groups(wallet) + + assert wallet.user.groups.filter(name="popular").exists() + + +@pytest.mark.django_db +@override_settings( + SIWE_DJANGO={ + "DOMAIN": "testserver", + "URI": "http://testserver/", + "TOKEN_GATES": [ + { + "type": "efp_min_followers", + "threshold": 10, + "group": "popular", + } + ], + } +) +def test_sync_wallet_groups_removes_group_when_efp_gate_fails( + mocker, django_user_model +): + wallet = _make_wallet(django_user_model) + group = Group.objects.create(name="popular") + wallet.user.groups.add(group) + mocker.patch( + "siwe_django.gates.fetch_efp_stats", + return_value={"followers_count": 1, "following_count": 1}, + ) + + sync_wallet_groups(wallet) + + assert not wallet.user.groups.filter(name="popular").exists() diff --git a/tests/test_eip4361_strict.py b/tests/test_eip4361_strict.py new file mode 100644 index 0000000..75d0225 --- /dev/null +++ b/tests/test_eip4361_strict.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from django.test import RequestFactory, override_settings +from eth_account import Account + +from siwe_django.services import ( + InvalidSignature, + eth_identity_kit_nonce_payload, + issue_nonce, + verify_siwe_message, +) + +from .helpers import build_message, sign_message + + +def _iso(dt: datetime) -> str: + return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def _request_with_session(): + request = RequestFactory().get("/") + from django.contrib.sessions.backends.db import SessionStore + + session = SessionStore() + session.create() + request.session = session + return request + + +@pytest.mark.django_db +def test_resources_subset_passes(): + account = Account.create() + request = _request_with_session() + nonce = issue_nonce( + request, resources=["https://example.com/a", "https://example.com/b"] + ) + message = build_message( + account, + nonce.nonce, + resources=["https://example.com/a"], + ) + signature = sign_message(account, message) + + identity = verify_siwe_message(message, signature, request) + + assert identity.address == account.address + + +@pytest.mark.django_db +def test_resources_outside_issued_fails(): + account = Account.create() + request = _request_with_session() + nonce = issue_nonce(request, resources=["https://example.com/a"]) + message = build_message( + account, + nonce.nonce, + resources=["https://example.com/c"], + ) + signature = sign_message(account, message) + + with pytest.raises(InvalidSignature, match="resources are not authorized"): + verify_siwe_message(message, signature, request) + + +@pytest.mark.django_db +def test_resources_omitted_when_required_fails(): + account = Account.create() + request = _request_with_session() + nonce = issue_nonce(request, resources=["https://example.com/a"]) + message = build_message(account, nonce.nonce) + signature = sign_message(account, message) + + with pytest.raises(InvalidSignature, match="resources are not authorized"): + verify_siwe_message(message, signature, request) + + +@pytest.mark.django_db +def test_request_id_mismatch_fails(): + account = Account.create() + request = _request_with_session() + nonce = issue_nonce(request, request_id="req-123") + message = build_message(account, nonce.nonce, request_id="req-999") + signature = sign_message(account, message) + + with pytest.raises(InvalidSignature): + verify_siwe_message(message, signature, request) + + +@pytest.mark.django_db +def test_request_id_match_passes(): + account = Account.create() + request = _request_with_session() + nonce = issue_nonce(request, request_id="req-123") + message = build_message(account, nonce.nonce, request_id="req-123") + signature = sign_message(account, message) + + identity = verify_siwe_message(message, signature, request) + + assert identity.address == account.address + + +@pytest.mark.django_db +def test_not_before_mismatch_fails(): + account = Account.create() + request = _request_with_session() + bound = datetime(2026, 4, 28, tzinfo=timezone.utc) + nonce = issue_nonce(request, not_before=bound) + message = build_message( + account, + nonce.nonce, + not_before=_iso(datetime(2026, 5, 1, tzinfo=timezone.utc)), + ) + signature = sign_message(account, message) + + with pytest.raises(InvalidSignature, match="Not Before"): + verify_siwe_message(message, signature, request) + + +@pytest.mark.django_db +def test_not_before_missing_when_required_fails(): + account = Account.create() + request = _request_with_session() + nonce = issue_nonce( + request, not_before=datetime(2026, 4, 28, tzinfo=timezone.utc) + ) + message = build_message(account, nonce.nonce) + signature = sign_message(account, message) + + with pytest.raises(InvalidSignature, match="Not Before"): + verify_siwe_message(message, signature, request) + + +@pytest.mark.django_db +@override_settings(SIWE_DJANGO={"DOMAIN": "testserver", "URI": "http://testserver/"}) +def test_clock_skew_tolerance_default_60s(): + account = Account.create() + request = _request_with_session() + nonce = issue_nonce(request) + issued_at = datetime.now(tz=timezone.utc) - timedelta(seconds=120) + expiration = datetime.now(tz=timezone.utc) - timedelta(seconds=30) + message = build_message( + account, + nonce.nonce, + issued_at=_iso(issued_at), + expiration_time=_iso(expiration), + ) + signature = sign_message(account, message) + + identity = verify_siwe_message(message, signature, request) + + assert identity.address == account.address + + +@pytest.mark.django_db +@override_settings( + SIWE_DJANGO={ + "DOMAIN": "testserver", + "URI": "http://testserver/", + "CLOCK_SKEW_SECONDS": 0, + } +) +def test_clock_skew_zero_rejects_just_expired(): + account = Account.create() + request = _request_with_session() + nonce = issue_nonce(request) + issued_at = datetime.now(tz=timezone.utc) - timedelta(seconds=120) + expiration = datetime.now(tz=timezone.utc) - timedelta(seconds=5) + message = build_message( + account, + nonce.nonce, + issued_at=_iso(issued_at), + expiration_time=_iso(expiration), + ) + signature = sign_message(account, message) + + with pytest.raises(InvalidSignature): + verify_siwe_message(message, signature, request) + + +@pytest.mark.django_db +def test_eth_identity_kit_payload_includes_optional_fields(): + request = _request_with_session() + nonce = issue_nonce( + request, + resources=["https://example.com/scope"], + request_id="req-42", + not_before=datetime(2026, 4, 28, tzinfo=timezone.utc), + ) + + payload = eth_identity_kit_nonce_payload(nonce) + + params = payload["messageParams"] + assert params["resources"] == ["https://example.com/scope"] + assert params["requestId"] == "req-42" + assert params["notBefore"].startswith("2026-04-28") + + +@pytest.mark.django_db +def test_eth_identity_kit_payload_omits_unset_optional_fields(): + request = _request_with_session() + nonce = issue_nonce(request) + + payload = eth_identity_kit_nonce_payload(nonce) + + params = payload["messageParams"] + assert "resources" not in params + assert "requestId" not in params + assert "notBefore" not in params diff --git a/tests/test_eip6492.py b/tests/test_eip6492.py new file mode 100644 index 0000000..1b96b7a --- /dev/null +++ b/tests/test_eip6492.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import pytest +from django.test import RequestFactory, override_settings +from eth_account import Account + +from siwe_django.services import ( + InvalidSignature, + issue_nonce, + verify_siwe_message, +) + +from .helpers import build_message, sign_message + +EIP6492_MAGIC_SUFFIX = ( + "6492649264926492649264926492649264926492649264926492649264926492" +) + + +def _request_with_session(): + request = RequestFactory().get("/") + from django.contrib.sessions.backends.db import SessionStore + + session = SessionStore() + session.create() + request.session = session + return request + + +def _wrap_eip6492(signature_hex: str) -> str: + raw = signature_hex[2:] if signature_hex.startswith("0x") else signature_hex + return f"0x{raw}{EIP6492_MAGIC_SUFFIX}" + + +@pytest.mark.django_db +@override_settings( + SIWE_DJANGO={ + "DOMAIN": "testserver", + "URI": "http://testserver/", + "RPC_URLS": {1: "https://example.invalid/rpc"}, + } +) +def test_eip6492_wrapped_signature_uses_contract_path(mocker): + account = Account.create() + other_address = Account.create().address + request = _request_with_session() + nonce = issue_nonce(request) + message = build_message(account, nonce.nonce) + + forged_message = message.replace(account.address, other_address) + eoa_sig = sign_message(account, forged_message) + contract_sig = _wrap_eip6492(eoa_sig) + + contract_check = mocker.patch( + "siwe.siwe.check_contract_wallet_signature", return_value=True + ) + + identity = verify_siwe_message(forged_message, contract_sig, request) + + assert identity.address == other_address + assert contract_check.called + passed_sig = contract_check.call_args.kwargs.get( + "signature" + ) or contract_check.call_args.args[2] + assert passed_sig == contract_sig + + +@pytest.mark.django_db +@override_settings( + SIWE_DJANGO={"DOMAIN": "testserver", "URI": "http://testserver/", "RPC_URLS": {}} +) +def test_eip6492_signature_without_rpc_fails(): + account = Account.create() + other_address = Account.create().address + request = _request_with_session() + nonce = issue_nonce(request) + message = build_message(account, nonce.nonce) + + forged_message = message.replace(account.address, other_address) + eoa_sig = sign_message(account, forged_message) + contract_sig = _wrap_eip6492(eoa_sig) + + with pytest.raises(InvalidSignature): + verify_siwe_message(forged_message, contract_sig, request) + + +@pytest.mark.django_db +@override_settings( + SIWE_DJANGO={ + "DOMAIN": "testserver", + "URI": "http://testserver/", + "RPC_URLS": {1: "https://example.invalid/rpc"}, + } +) +def test_eip6492_contract_check_failure_rejects(mocker): + account = Account.create() + other_address = Account.create().address + request = _request_with_session() + nonce = issue_nonce(request) + message = build_message(account, nonce.nonce) + + forged_message = message.replace(account.address, other_address) + eoa_sig = sign_message(account, forged_message) + contract_sig = _wrap_eip6492(eoa_sig) + + mocker.patch("siwe.siwe.check_contract_wallet_signature", return_value=False) + + with pytest.raises(InvalidSignature): + verify_siwe_message(forged_message, contract_sig, request) diff --git a/tests/test_ethid_efp.py b/tests/test_ethid_efp.py new file mode 100644 index 0000000..86394dc --- /dev/null +++ b/tests/test_ethid_efp.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import io +import json + +import pytest + +from siwe_django.ethid import ( + fetch_efp_follower_state, + fetch_efp_followers, + fetch_efp_following, + fetch_efp_stats, + fetch_efp_tags, + fetch_ens_record, +) + + +class _FakeResponse: + def __init__(self, payload, *, status: int = 200): + self.status = status + self._body = json.dumps(payload).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return self._body + + +def _patch_urlopen(mocker, payload, *, status: int = 200): + return mocker.patch( + "siwe_django.ethid.urlopen", + return_value=_FakeResponse(payload, status=status), + ) + + +def test_fetch_efp_stats_normalises_keys(mocker): + _patch_urlopen(mocker, {"followers_count": 12, "following_count": 7}) + + result = fetch_efp_stats("alice.eth") + + assert result == {"followers_count": 12, "following_count": 7} + + +def test_fetch_efp_stats_accepts_camel_case(mocker): + _patch_urlopen(mocker, {"followersCount": 4, "followingCount": 1}) + + assert fetch_efp_stats("alice.eth") == { + "followers_count": 4, + "following_count": 1, + } + + +def test_fetch_efp_stats_returns_zeros_on_error(mocker): + mocker.patch("siwe_django.ethid.urlopen", side_effect=OSError("boom")) + + assert fetch_efp_stats("alice.eth") == { + "followers_count": 0, + "following_count": 0, + } + + +def test_fetch_efp_follower_state(mocker): + _patch_urlopen(mocker, {"follow": True, "block": False, "mute": False}) + + result = fetch_efp_follower_state("alice.eth", "bob.eth") + + assert result == {"follow": True, "block": False, "mute": False} + + +def test_fetch_efp_follower_state_defaults_to_false_on_missing(mocker): + _patch_urlopen(mocker, {}) + + result = fetch_efp_follower_state("alice.eth", "bob.eth") + + assert result == {"follow": False, "block": False, "mute": False} + + +def test_fetch_efp_followers_passes_limit_offset(mocker): + follower = {"address": "0x0000000000000000000000000000000000000001"} + patched = _patch_urlopen(mocker, [follower]) + + result = fetch_efp_followers("hub.eth", limit=50, offset=100) + + assert result == [follower] + request_arg = patched.call_args.args[0] + assert "limit=50" in request_arg.full_url + assert "offset=100" in request_arg.full_url + + +def test_fetch_efp_following_unwraps_dict_response(mocker): + payload = {"following": [{"address": "0xabc"}], "next": None} + _patch_urlopen(mocker, payload) + + result = fetch_efp_following("hub.eth") + + assert result == [{"address": "0xabc"}] + + +def test_fetch_efp_tags_filters_by_source(mocker): + payload = [ + {"tag": "vip", "address": "0xHUB"}, + {"tag": "spammer", "address": "0xOTHER"}, + ] + _patch_urlopen(mocker, payload) + + filtered = fetch_efp_tags("alice.eth", source="0xhub") + + assert filtered == [{"tag": "vip", "address": "0xHUB"}] + + +def test_fetch_ens_record(mocker): + _patch_urlopen(mocker, {"name": "alice.eth", "avatar": "https://x"}) + + record = fetch_ens_record("alice.eth") + + assert record["name"] == "alice.eth" + + +@pytest.mark.parametrize( + ("func", "args"), + [ + (fetch_efp_followers, ("alice.eth",)), + (fetch_efp_following, ("alice.eth",)), + (fetch_efp_tags, ("alice.eth",)), + ], +) +def test_list_helpers_return_empty_on_error(func, args, mocker): + mocker.patch("siwe_django.ethid.urlopen", side_effect=OSError("boom")) + + assert func(*args) == [] + + +def test_list_helpers_handle_non_json(mocker): + response = io.BytesIO(b"not json") + response.status = 200 + response.__enter__ = lambda self: self # type: ignore[attr-defined] + response.__exit__ = lambda self, *exc: False # type: ignore[attr-defined] + response.read = lambda: b"not json" # type: ignore[attr-defined] + mocker.patch("siwe_django.ethid.urlopen", return_value=response) + + assert fetch_efp_followers("alice.eth") == [] diff --git a/tests/test_nonce_store.py b/tests/test_nonce_store.py new file mode 100644 index 0000000..26655a6 --- /dev/null +++ b/tests/test_nonce_store.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from django.test import override_settings + +from siwe_django.nonce_store import ( + DjangoOrmNonceStore, + NonceRecord, + RedisNonceStore, + get_nonce_store, +) + + +def _future(seconds: int = 300) -> datetime: + return datetime.now(tz=timezone.utc) + timedelta(seconds=seconds) + + +# ----------------------------------------------------------------------------- +# DjangoOrmNonceStore +# ----------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_orm_store_round_trips_record(): + store = DjangoOrmNonceStore() + + record = store.save( + nonce="abc123", + expires_at=_future(), + domain="example.com", + request_id="req-1", + resources=["https://example.com/scope"], + ) + loaded = store.load("abc123") + + assert isinstance(record, NonceRecord) + assert loaded == record + + +@pytest.mark.django_db +def test_orm_store_load_missing_returns_none(): + store = DjangoOrmNonceStore() + + assert store.load("does-not-exist") is None + + +@pytest.mark.django_db +def test_orm_store_consume_is_atomic_single_use(): + store = DjangoOrmNonceStore() + store.save(nonce="once", expires_at=_future()) + + assert store.consume("once") is True + assert store.consume("once") is False + + +@pytest.mark.django_db +def test_orm_store_consume_rejects_expired(): + store = DjangoOrmNonceStore() + store.save( + nonce="expired", expires_at=datetime.now(tz=timezone.utc) - timedelta(seconds=1) + ) + + assert store.consume("expired") is False + + +# ----------------------------------------------------------------------------- +# RedisNonceStore +# ----------------------------------------------------------------------------- + + +class _FakeRedisClient: + """Minimal in-memory stand-in supporting the subset of redis-py we use.""" + + def __init__(self): + self._store: dict[str, str] = {} + + def set(self, key, value, ex=None, nx=False): + if nx and key in self._store: + return None + self._store[key] = value + return True + + def get(self, key): + return self._store.get(key) + + def delete(self, key): + existed = key in self._store + self._store.pop(key, None) + return 1 if existed else 0 + + +def test_redis_store_round_trips_record(): + client = _FakeRedisClient() + store = RedisNonceStore(client=client) + + saved = store.save( + nonce="r-1", + expires_at=_future(), + domain="example.com", + not_before=datetime(2026, 4, 28, tzinfo=timezone.utc), + request_id="req-2", + resources=["https://example.com"], + ) + loaded = store.load("r-1") + + assert loaded is not None + assert loaded.nonce == saved.nonce + assert loaded.domain == saved.domain + assert loaded.not_before == saved.not_before + assert loaded.request_id == saved.request_id + assert loaded.resources == saved.resources + + +def test_redis_store_load_missing_returns_none(): + store = RedisNonceStore(client=_FakeRedisClient()) + assert store.load("missing") is None + + +def test_redis_store_consume_is_single_use(): + client = _FakeRedisClient() + store = RedisNonceStore(client=client) + store.save(nonce="single", expires_at=_future()) + + assert store.consume("single") is True + assert store.consume("single") is False + + +def test_redis_store_save_uses_nx_to_prevent_overwrite(): + client = _FakeRedisClient() + store = RedisNonceStore(client=client) + store.save(nonce="nx", expires_at=_future(), domain="first.example.com") + store.save(nonce="nx", expires_at=_future(), domain="second.example.com") + + loaded = store.load("nx") + assert loaded is not None + assert loaded.domain == "first.example.com" + + +# ----------------------------------------------------------------------------- +# get_nonce_store resolution +# ----------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_get_nonce_store_default_is_orm(): + store = get_nonce_store() + + assert isinstance(store, DjangoOrmNonceStore) + + +@pytest.mark.django_db +@override_settings( + SIWE_DJANGO={ + "DOMAIN": "testserver", + "URI": "http://testserver/", + "NONCE_STORE": "tests.test_nonce_store.RedisStoreFactory", + } +) +def test_get_nonce_store_honours_dotted_path(): + store = get_nonce_store() + + assert isinstance(store, RedisNonceStore) + + +def RedisStoreFactory() -> RedisNonceStore: + return RedisNonceStore(client=_FakeRedisClient()) diff --git a/tests/test_recap.py b/tests/test_recap.py new file mode 100644 index 0000000..d19d588 --- /dev/null +++ b/tests/test_recap.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import base64 +import json + +import pytest + +from siwe_django.recap import ( + RECAP_URI_PREFIX, + build_recap_statement, + decode_recap, + encode_recap, + find_recap_in_resources, +) + + +def _b64url_decode(token: str) -> bytes: + padding = "=" * (-len(token) % 4) + return base64.urlsafe_b64decode(token + padding) + + +def test_encode_recap_round_trips(): + att = { + "https://example.com": { + "crud/read": [{}], + "crud/update": [{"max": 5}], + } + } + + uri = encode_recap(att) + + assert uri.startswith(RECAP_URI_PREFIX) + decoded = decode_recap(uri) + assert decoded == {"att": dict(att)} + + +def test_encode_recap_includes_prf(): + att = {"https://example.com": {"crud/read": [{}]}} + proofs = ["ipfs://baf..."] + + uri = encode_recap(att, prf=proofs) + decoded = decode_recap(uri) + + assert decoded is not None + assert decoded["prf"] == proofs + + +def test_encode_recap_uses_unpadded_base64url(): + att = {"https://example.com": {"a/b": [{}]}} + + uri = encode_recap(att) + token = uri[len(RECAP_URI_PREFIX) :] + + assert "=" not in token + payload = json.loads(_b64url_decode(token).decode("utf-8")) + assert payload["att"]["https://example.com"]["a/b"] == [{}] + + +def test_encode_recap_rejects_empty_att(): + with pytest.raises(ValueError, match="at least one resource"): + encode_recap({}) + + +def test_decode_recap_returns_none_for_non_recap_uri(): + assert decode_recap("https://example.com") is None + assert decode_recap("urn:other:abc") is None + + +def test_decode_recap_returns_none_for_invalid_payload(): + assert decode_recap(f"{RECAP_URI_PREFIX}!!!notbase64!!!") is None + bad = base64.urlsafe_b64encode(b'{"missing": "att"}').rstrip(b"=").decode() + assert decode_recap(f"{RECAP_URI_PREFIX}{bad}") is None + + +def test_find_recap_in_resources_returns_last_entry(): + other = "https://example.com/scope" + att = {"https://example.com": {"crud/read": [{}]}} + recap_uri = encode_recap(att) + + assert find_recap_in_resources([other, recap_uri]) == {"att": dict(att)} + + +def test_find_recap_in_resources_returns_none_when_last_is_not_recap(): + att = {"https://example.com": {"crud/read": [{}]}} + recap_uri = encode_recap(att) + + assert find_recap_in_resources([recap_uri, "https://example.com"]) is None + assert find_recap_in_resources(None) is None + assert find_recap_in_resources([]) is None + + +def test_build_recap_statement_lists_abilities_in_sorted_order(): + att = { + "https://example.com": { + "crud/update": [{}], + "crud/read": [{}], + } + } + + statement = build_recap_statement(att) + + assert "I further authorize" in statement + assert "(1) https://example.com: crud/read, crud/update." in statement + + +def test_build_recap_statement_empty_when_no_abilities(): + assert build_recap_statement({}) == "" + assert build_recap_statement({"https://example.com": {}}) == "" diff --git a/tests/test_stepup.py b/tests/test_stepup.py new file mode 100644 index 0000000..5634ef7 --- /dev/null +++ b/tests/test_stepup.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from django.http import HttpResponse +from django.test import RequestFactory + +from siwe_django.stepup import ( + SESSION_KEY, + has_recent_siwe, + mark_recent_siwe, + require_recent_siwe, +) + +from .helpers import post_json, signed_payload + + +def _request_with_session(): + request = RequestFactory().get("/") + from django.contrib.sessions.backends.db import SessionStore + + session = SessionStore() + session.create() + request.session = session + return request + + +@pytest.mark.django_db +def test_mark_and_has_recent_siwe(): + request = _request_with_session() + + assert has_recent_siwe(request, seconds=300) is False + mark_recent_siwe(request) + assert has_recent_siwe(request, seconds=300) is True + + +@pytest.mark.django_db +def test_has_recent_siwe_returns_false_when_stale(): + request = _request_with_session() + stale = datetime.now(tz=timezone.utc) - timedelta(seconds=600) + request.session[SESSION_KEY] = stale.isoformat() + + assert has_recent_siwe(request, seconds=300) is False + + +@pytest.mark.django_db +def test_has_recent_siwe_handles_garbage(): + request = _request_with_session() + request.session[SESSION_KEY] = "not-a-date" + + assert has_recent_siwe(request, seconds=300) is False + + +@pytest.mark.django_db +def test_require_recent_siwe_blocks_when_missing(): + @require_recent_siwe(seconds=300) + def view(request): + return HttpResponse("ok") + + response = view(_request_with_session()) + + assert response.status_code == 403 + assert b"stepup_required" in response.content + + +@pytest.mark.django_db +def test_require_recent_siwe_allows_when_fresh(): + @require_recent_siwe(seconds=300) + def view(request): + return HttpResponse("ok") + + request = _request_with_session() + mark_recent_siwe(request) + + response = view(request) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_verify_endpoint_marks_recent_siwe(client): + payload = signed_payload(client) + post_json( + client, + "/siwe/verify/", + {"message": payload["message"], "signature": payload["signature"]}, + ) + + assert SESSION_KEY in client.session + + +@pytest.mark.django_db +def test_reauth_endpoint_requires_authenticated_user(client): + payload = signed_payload(client) + response = post_json( + client, + "/siwe/reauth/", + {"message": payload["message"], "signature": payload["signature"]}, + ) + + assert response.status_code == 401 + + +@pytest.mark.django_db +def test_reauth_endpoint_succeeds_for_linked_wallet(client): + payload = signed_payload(client) + post_json( + client, + "/siwe/verify/", + {"message": payload["message"], "signature": payload["signature"]}, + ) + session = client.session + session[SESSION_KEY] = ( + datetime.now(tz=timezone.utc) - timedelta(seconds=600) + ).isoformat() + session.save() + fresh = signed_payload(client, account=payload["account"]) + + response = post_json( + client, + "/siwe/reauth/", + {"message": fresh["message"], "signature": fresh["signature"]}, + ) + + assert response.status_code == 200, response.content + assert response.json()["address"] == payload["account"].address + assert SESSION_KEY in client.session + + +@pytest.mark.django_db +def test_reauth_endpoint_rejects_other_wallet(client): + payload = signed_payload(client) + post_json( + client, + "/siwe/verify/", + {"message": payload["message"], "signature": payload["signature"]}, + ) + other = signed_payload(client) + + response = post_json( + client, + "/siwe/reauth/", + {"message": other["message"], "signature": other["signature"]}, + ) + + assert response.status_code == 403 + assert response.json()["error"] == "wallet_not_linked" diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..9136e56 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import hashlib +import hmac +import json + +import pytest +from django.test import override_settings + +from siwe_django.webhooks import ( + SIGNATURE_HEADER, + deliver, + dispatch, + event_payload, + matching_subscriptions, + sign_payload, +) + + +def test_sign_payload_uses_hmac_sha256(): + body = b'{"event":"verify_succeeded"}' + secret = "shh" + + expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + + assert sign_payload(secret, body) == expected + + +@override_settings( + SIWE_DJANGO={ + "DOMAIN": "testserver", + "URI": "http://testserver/", + "WEBHOOKS": [ + {"event": "verify_succeeded", "url": "https://a", "secret": "x"}, + {"event": "*", "url": "https://b", "secret": "y"}, + {"event": "verify_failed", "url": "https://c", "secret": "z"}, + ], + } +) +def test_matching_subscriptions_includes_wildcards(): + matched = matching_subscriptions("verify_succeeded") + + urls = [m["url"] for m in matched] + assert "https://a" in urls + assert "https://b" in urls + assert "https://c" not in urls + + +def test_event_payload_shape(): + payload = event_payload( + "verify_succeeded", + address="0xabc", + user_id="42", + metadata={"chain_id": 1}, + ) + + assert payload == { + "event": "verify_succeeded", + "address": "0xabc", + "user_id": "42", + "success": True, + "error_code": "", + "metadata": {"chain_id": 1}, + } + + +def test_deliver_signs_body_and_posts(mocker): + captured = {} + + class _Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def _urlopen(request, timeout): + captured["url"] = request.full_url + captured["headers"] = dict(request.headers) + captured["data"] = request.data + captured["timeout"] = timeout + return _Response() + + mocker.patch("siwe_django.webhooks.urlrequest.urlopen", side_effect=_urlopen) + + payload = event_payload("verify_succeeded", address="0xabc") + ok = deliver({"url": "https://example.com", "secret": "xyz"}, payload) + + assert ok is True + assert captured["url"] == "https://example.com" + expected_body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + assert captured["data"] == expected_body + expected_sig = sign_payload("xyz", expected_body) + sig_value = next( + v + for k, v in captured["headers"].items() + if k.lower() == SIGNATURE_HEADER.lower() + ) + assert sig_value == expected_sig + + +def test_deliver_returns_false_when_url_or_secret_missing(): + assert deliver({"url": "", "secret": "x"}, {}) is False + assert deliver({"url": "https://x", "secret": ""}, {}) is False + + +def test_deliver_returns_false_on_network_error(mocker): + mocker.patch( + "siwe_django.webhooks.urlrequest.urlopen", + side_effect=OSError("boom"), + ) + + assert ( + deliver({"url": "https://x", "secret": "y"}, event_payload("e")) is False + ) + + +def test_deliver_returns_false_when_payload_not_json_serialisable(mocker): + urlopen = mocker.patch("siwe_django.webhooks.urlrequest.urlopen") + + class _NotSerialisable: + pass + + payload = {"event": "verify_succeeded", "metadata": {"obj": _NotSerialisable()}} + + assert deliver({"url": "https://x", "secret": "y"}, payload) is False + assert urlopen.called is False + + +@override_settings( + SIWE_DJANGO={ + "DOMAIN": "testserver", + "URI": "http://testserver/", + "WEBHOOKS": [ + {"event": "verify_succeeded", "url": "https://hook", "secret": "s"} + ], + } +) +def test_dispatch_calls_deliver_for_matching_subscriptions(mocker): + mock_deliver = mocker.patch("siwe_django.webhooks.deliver", return_value=True) + + delivered = dispatch("verify_succeeded", event_payload("verify_succeeded")) + + assert delivered == 1 + assert mock_deliver.call_count == 1 + + +@override_settings( + SIWE_DJANGO={ + "DOMAIN": "testserver", + "URI": "http://testserver/", + "WEBHOOKS": [ + {"event": "verify_succeeded", "url": "https://hook", "secret": "s"} + ], + "WEBHOOK_DISPATCHER": "tests.test_webhooks.fake_dispatcher", + } +) +def test_dispatch_respects_custom_dispatcher(): + fake_dispatcher.calls = [] # type: ignore[attr-defined] + + delivered = dispatch("verify_succeeded", event_payload("verify_succeeded")) + + assert delivered == 1 + assert len(fake_dispatcher.calls) == 1 # type: ignore[attr-defined] + + +def fake_dispatcher(event, payload, subscriptions): + fake_dispatcher.calls.append((event, payload, subscriptions)) + + +fake_dispatcher.calls = [] # type: ignore[attr-defined] + + +@pytest.mark.django_db +@override_settings( + SIWE_DJANGO={ + "DOMAIN": "testserver", + "URI": "http://testserver/", + "WEBHOOKS": [{"event": "*", "url": "https://hook", "secret": "s"}], + } +) +def test_record_event_dispatches_webhook(mocker, django_user_model): + mock_deliver = mocker.patch("siwe_django.webhooks.deliver", return_value=True) + from django.test import RequestFactory + + from siwe_django.audit import record_event + from siwe_django.models import SiweAuthEvent + + request = RequestFactory().get("/", REMOTE_ADDR="203.0.113.1") + record_event( + request, + SiweAuthEvent.EVENT_VERIFY_SUCCESS, + address="0xabc", + ) + + assert mock_deliver.called + payload = mock_deliver.call_args.args[1] + assert payload["event"] == SiweAuthEvent.EVENT_VERIFY_SUCCESS + assert payload["address"] == "0xabc" diff --git a/uv.lock b/uv.lock index 7f249fe..21e455f 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,9 @@ version = 1 revision = 1 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", "python_full_version < '3.12'", ] @@ -157,6 +159,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, ] +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -468,6 +479,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/a9/a3284a64216f31a886ff216621c6b3806ca7ad7388908f68fcab9007c881/ckzg-2.1.7-cp314-cp314t-win_amd64.whl", hash = "sha256:2cdcc023d842900564d6070e397cab0d04fd393e6af07d60bdd1c97dc3ff09fd", size = 102660 }, ] +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -662,7 +685,9 @@ name = "django" version = "6.0.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", ] dependencies = [ { name = "asgiref", marker = "python_full_version >= '3.12'" }, @@ -687,6 +712,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844 }, ] +[[package]] +name = "drf-spectacular" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "djangorestframework" }, + { name = "inflection" }, + { name = "jsonschema" }, + { name = "pyyaml" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/0e/a4f50d83e76cbe797eda88fc0083c8ca970cfa362b5586359ef06ec6f70a/drf_spectacular-0.29.0.tar.gz", hash = "sha256:0a069339ea390ce7f14a75e8b5af4a0860a46e833fd4af027411a3e94fc1a0cc", size = 241722 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d9/502c56fc3ca960075d00956283f1c44e8cafe433dada03f9ed2821f3073b/drf_spectacular-0.29.0-py3-none-any.whl", hash = "sha256:d1ee7c9535d89848affb4427347f7c4a22c5d22530b8842ef133d7b72e19b41a", size = 105433 }, +] + [[package]] name = "eth-abi" version = "5.2.0" @@ -969,6 +1012,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789 }, ] +[[package]] +name = "inflection" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454 }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -978,6 +1030,122 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, +] + +[[package]] +name = "libcst" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, + { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/52/97d5454dee9d014821fe0c88f3dc0e83131b97dd074a4d49537056a75475/libcst-1.8.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a20c5182af04332cc94d8520792befda06d73daf2865e6dddc5161c72ea92cb9", size = 2211698 }, + { url = "https://files.pythonhosted.org/packages/6c/a4/d1205985d378164687af3247a9c8f8bdb96278b0686ac98ab951bc6d336a/libcst-1.8.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36473e47cb199b7e6531d653ee6ffed057de1d179301e6c67f651f3af0b499d6", size = 2093104 }, + { url = "https://files.pythonhosted.org/packages/9e/de/1338da681b7625b51e584922576d54f1b8db8fc7ff4dc79121afc5d4d2cd/libcst-1.8.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:06fc56335a45d61b7c1b856bfab4587b84cfe31e9d6368f60bb3c9129d900f58", size = 2237419 }, + { url = "https://files.pythonhosted.org/packages/50/06/ee66f2d83b870534756e593d464d8b33b0914c224dff3a407e0f74dc04e0/libcst-1.8.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6b23d14a7fc0addd9795795763af26b185deb7c456b1e7cc4d5228e69dab5ce8", size = 2300820 }, + { url = "https://files.pythonhosted.org/packages/9c/ca/959088729de8e0eac8dd516e4fb8623d8d92bad539060fa85c9e94d418a5/libcst-1.8.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16cfe0cfca5fd840e1fb2c30afb628b023d3085b30c3484a79b61eae9d6fe7ba", size = 2301201 }, + { url = "https://files.pythonhosted.org/packages/c2/4c/2a21a8c452436097dfe1da277f738c3517f3f728713f16d84b9a3d67ca8d/libcst-1.8.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:455f49a93aea4070132c30ebb6c07c2dea0ba6c1fde5ffde59fc45dbb9cfbe4b", size = 2408213 }, + { url = "https://files.pythonhosted.org/packages/3e/26/8f7b671fad38a515bb20b038718fd2221ab658299119ac9bcec56c2ced27/libcst-1.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:72cca15800ffc00ba25788e4626189fe0bc5fe2a0c1cb4294bce2e4df21cc073", size = 2119189 }, + { url = "https://files.pythonhosted.org/packages/5b/bf/ffb23a48e27001165cc5c81c5d9b3d6583b21b7f5449109e03a0020b060c/libcst-1.8.6-cp310-cp310-win_arm64.whl", hash = "sha256:6cad63e3a26556b020b634d25a8703b605c0e0b491426b3e6b9e12ed20f09100", size = 2001736 }, + { url = "https://files.pythonhosted.org/packages/dc/15/95c2ecadc0fb4af8a7057ac2012a4c0ad5921b9ef1ace6c20006b56d3b5f/libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073", size = 2211289 }, + { url = "https://files.pythonhosted.org/packages/80/c3/7e1107acd5ed15cf60cc07c7bb64498a33042dc4821874aea3ec4942f3cd/libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6", size = 2092927 }, + { url = "https://files.pythonhosted.org/packages/c1/ff/0d2be87f67e2841a4a37d35505e74b65991d30693295c46fc0380ace0454/libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978", size = 2237002 }, + { url = "https://files.pythonhosted.org/packages/69/99/8c4a1b35c7894ccd7d33eae01ac8967122f43da41325223181ca7e4738fe/libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532", size = 2301048 }, + { url = "https://files.pythonhosted.org/packages/9b/8b/d1aa811eacf936cccfb386ae0585aa530ea1221ccf528d67144e041f5915/libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64", size = 2300675 }, + { url = "https://files.pythonhosted.org/packages/c6/6b/7b65cd41f25a10c1fef2389ddc5c2b2cc23dc4d648083fa3e1aa7e0eeac2/libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b", size = 2407934 }, + { url = "https://files.pythonhosted.org/packages/c5/8b/401cfff374bb3b785adfad78f05225225767ee190997176b2a9da9ed9460/libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f", size = 2119247 }, + { url = "https://files.pythonhosted.org/packages/f1/17/085f59eaa044b6ff6bc42148a5449df2b7f0ba567307de7782fe85c39ee2/libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c", size = 2001774 }, + { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166 }, + { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726 }, + { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755 }, + { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473 }, + { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899 }, + { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239 }, + { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660 }, + { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824 }, + { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386 }, + { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945 }, + { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818 }, + { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289 }, + { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230 }, + { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519 }, + { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853 }, + { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808 }, + { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553 }, + { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717 }, + { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834 }, + { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107 }, + { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672 }, + { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661 }, + { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068 }, + { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181 }, + { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202 }, + { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581 }, + { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495 }, + { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466 }, + { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264 }, + { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572 }, + { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917 }, + { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748 }, + { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980 }, + { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828 }, + { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568 }, + { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523 }, + { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044 }, + { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605 }, + { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581 }, + { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000 }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -1146,6 +1314,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -1517,6 +1697,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540 }, ] +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027 }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146 }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792 }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772 }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723 }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478 }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159 }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779 }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331 }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879 }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277 }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650 }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755 }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403 }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581 }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579 }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753 }, +] + +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772 }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, +] + [[package]] name = "regex" version = "2026.4.4" @@ -1653,6 +1959,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947 }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, +] + [[package]] name = "rlp" version = "4.1.0" @@ -1665,6 +1984,128 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/fb/e4c0ced9893b84ac95b7181d69a9786ce5879aeb3bbbcbba80a164f85d6a/rlp-4.1.0-py3-none-any.whl", hash = "sha256:8eca394c579bad34ee0b937aecb96a57052ff3716e19c7a578883e767bc5da6f", size = 19973 }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490 }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751 }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696 }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136 }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699 }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022 }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522 }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579 }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305 }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503 }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322 }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792 }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901 }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823 }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157 }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676 }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938 }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932 }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830 }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033 }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828 }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683 }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583 }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496 }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669 }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011 }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406 }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024 }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069 }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086 }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053 }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763 }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951 }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622 }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492 }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080 }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680 }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589 }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289 }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737 }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120 }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782 }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463 }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868 }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887 }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904 }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945 }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783 }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021 }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589 }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025 }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895 }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799 }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731 }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027 }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020 }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139 }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224 }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645 }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443 }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375 }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850 }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812 }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841 }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149 }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843 }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507 }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949 }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790 }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217 }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806 }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341 }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768 }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099 }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192 }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080 }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841 }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670 }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005 }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112 }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049 }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661 }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606 }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126 }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371 }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298 }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604 }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391 }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868 }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747 }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795 }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330 }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194 }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340 }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765 }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834 }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470 }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630 }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148 }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030 }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570 }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532 }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292 }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128 }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542 }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004 }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063 }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099 }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177 }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015 }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736 }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981 }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782 }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191 }, +] + [[package]] name = "ruff" version = "0.15.12" @@ -1690,6 +2131,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821 }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + [[package]] name = "signinwithethereum" version = "5.0.1" @@ -1710,7 +2160,7 @@ wheels = [ [[package]] name = "siwe-django" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, @@ -1721,40 +2171,70 @@ dependencies = [ ] [package.optional-dependencies] +cli = [ + { name = "libcst" }, + { name = "questionary" }, + { name = "rich" }, + { name = "tomlkit" }, + { name = "typer" }, +] drf = [ { name = "djangorestframework" }, ] +openapi = [ + { name = "drf-spectacular" }, +] +redis = [ + { name = "redis" }, +] [package.dev-dependencies] dev = [ { name = "build" }, { name = "djangorestframework" }, { name = "eth-account" }, + { name = "libcst" }, { name = "pytest" }, { name = "pytest-django" }, { name = "pytest-mock" }, + { name = "questionary" }, + { name = "rich" }, { name = "ruff" }, + { name = "tomlkit" }, + { name = "typer" }, ] [package.metadata] requires-dist = [ { name = "django", specifier = ">=5.2,<6.1" }, { name = "djangorestframework", marker = "extra == 'drf'", specifier = ">=3.16" }, + { name = "drf-spectacular", marker = "extra == 'openapi'", specifier = ">=0.27" }, { name = "eth-utils", specifier = ">=2.2" }, + { name = "libcst", marker = "extra == 'cli'", specifier = ">=1.4" }, + { name = "questionary", marker = "extra == 'cli'", specifier = ">=2.0" }, + { name = "redis", marker = "extra == 'redis'", specifier = ">=5.0" }, + { name = "rich", marker = "extra == 'cli'", specifier = ">=13.7" }, { name = "signinwithethereum", specifier = ">=5,<6" }, + { name = "tomlkit", marker = "extra == 'cli'", specifier = ">=0.13" }, + { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12" }, { name = "web3", specifier = ">=7.3,<8" }, ] -provides-extras = ["drf"] +provides-extras = ["drf", "redis", "openapi", "cli"] [package.metadata.requires-dev] dev = [ { name = "build", specifier = ">=1.2" }, { name = "djangorestframework", specifier = ">=3.16" }, { name = "eth-account", specifier = ">=0.13" }, + { name = "libcst", specifier = ">=1.4" }, { name = "pytest", specifier = ">=8.1" }, { name = "pytest-django", specifier = ">=4.8" }, { name = "pytest-mock", specifier = ">=3.14" }, + { name = "questionary", specifier = ">=2.0" }, + { name = "rich", specifier = ">=13.7" }, { name = "ruff", specifier = ">=0.6" }, + { name = "tomlkit", specifier = ">=0.13" }, + { name = "typer", specifier = ">=0.12" }, ] [[package]] @@ -1820,6 +2300,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, ] +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310 }, +] + [[package]] name = "toolz" version = "1.1.0" @@ -1829,6 +2318,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093 }, ] +[[package]] +name = "typer" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/27/ede8cec7596e0041ba7e7b80b47d132562f56ff454313a16f6084e555c9f/typer-0.25.0.tar.gz", hash = "sha256:123eaf9f19bb40fd268310e12a542c0c6b4fab9c98d9d23342a01ff95e3ce930", size = 120150 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/72/193d4e586ec5a4db834a36bbeb47641a62f951f114ffd0fe5b1b46e8d56f/typer-0.25.0-py3-none-any.whl", hash = "sha256:ac01b48823d3db9a83c9e164338057eadbb1c9957a2a6b4eeb486669c560b5dc", size = 55993 }, +] + [[package]] name = "types-requests" version = "2.33.0.20260408" @@ -1871,6 +2375,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321 }, ] +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488 }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -1880,6 +2393,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, ] +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189 }, +] + [[package]] name = "web3" version = "7.15.0"