A social pastebin built on atproto.
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

init

Aly Raffauf c05dece4

+2574
+7
.dockerignore
··· 1 + .venv 2 + .git 3 + __pycache__ 4 + *.pyc 5 + morsel.db 6 + secrets.json 7 + .prettierignore
+52
.github/workflows/build.yml
··· 1 + name: Build and push container image 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + pull_request: 7 + branches: [main] 8 + 9 + env: 10 + REGISTRY: ghcr.io 11 + IMAGE_NAME: ${{ github.repository }} 12 + 13 + jobs: 14 + build: 15 + runs-on: ubuntu-latest 16 + permissions: 17 + contents: read 18 + packages: write 19 + 20 + steps: 21 + - name: Checkout 22 + uses: actions/checkout@v4 23 + 24 + - name: Set up Docker Buildx 25 + uses: docker/setup-buildx-action@v3 26 + 27 + - name: Log in to GitHub Container Registry 28 + if: github.event_name != 'pull_request' 29 + uses: docker/login-action@v3 30 + with: 31 + registry: ${{ env.REGISTRY }} 32 + username: ${{ github.actor }} 33 + password: ${{ secrets.GITHUB_TOKEN }} 34 + 35 + - name: Extract metadata 36 + id: meta 37 + uses: docker/metadata-action@v5 38 + with: 39 + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 40 + tags: | 41 + type=sha 42 + type=raw,value=latest,enable={{is_default_branch}} 43 + 44 + - name: Build and push 45 + uses: docker/build-push-action@v6 46 + with: 47 + context: . 48 + push: ${{ github.event_name != 'pull_request' }} 49 + tags: ${{ steps.meta.outputs.tags }} 50 + labels: ${{ steps.meta.outputs.labels }} 51 + cache-from: type=gha 52 + cache-to: type=gha,mode=max
+16
.gitignore
··· 1 + # Python-generated files 2 + __pycache__/ 3 + *.py[oc] 4 + build/ 5 + dist/ 6 + wheels/ 7 + *.egg-info 8 + 9 + # Virtual environments 10 + .venv 11 + 12 + # Secrets and data 13 + secrets.json 14 + morsel.db 15 + morsel.db-wal 16 + morsel.db-shm
+1
.prettierignore
··· 1 + templates/
+1
.python-version
··· 1 + 3.14
+26
Dockerfile
··· 1 + FROM python:3.14-slim 2 + 3 + WORKDIR /app 4 + 5 + # Install uv for fast dependency management 6 + COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv 7 + 8 + # Copy dependency files first (layer caching) 9 + COPY pyproject.toml uv.lock ./ 10 + 11 + # Install dependencies 12 + RUN uv sync --frozen --no-dev 13 + 14 + # Copy application code 15 + COPY main.py config.py identity.py atproto_oauth.py schema.sql ./ 16 + COPY templates/ templates/ 17 + 18 + # Create data directory for secrets and database 19 + RUN mkdir -p /data 20 + 21 + ENV MORSEL_DATA_DIR=/data 22 + ENV PYTHONUNBUFFERED=1 23 + 24 + EXPOSE 8000 25 + 26 + CMD ["uv", "run", "gunicorn", "main:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120"]
README.md

This is a binary file and will not be displayed.

+381
atproto_oauth.py
··· 1 + """ATProto OAuth helpers. 2 + 3 + Handles DPoP proof generation, PAR requests, token exchange, and 4 + authenticated PDS requests. Adapted from the Bluesky cookbook example. 5 + """ 6 + 7 + import json 8 + import time 9 + import urllib.request 10 + from typing import Any, Tuple 11 + 12 + import requests_hardened 13 + from authlib.common.security import generate_token 14 + from authlib.jose import JsonWebKey, jwt 15 + from authlib.oauth2.rfc7636 import create_s256_code_challenge 16 + from requests import Response 17 + 18 + # SSRF-safe HTTP client 19 + hardened_http = requests_hardened.Manager( 20 + requests_hardened.Config( 21 + default_timeout=(2, 10), 22 + never_redirect=True, 23 + ip_filter_enable=True, 24 + ip_filter_allow_loopback_ips=False, 25 + user_agent_override="Morsels", 26 + ) 27 + ) 28 + 29 + 30 + def is_safe_url(url): 31 + """Crude SSRF check — only allows HTTPS URLs with public hostnames.""" 32 + from urllib.parse import urlparse 33 + 34 + parts = urlparse(url) 35 + if not ( 36 + parts.scheme == "https" 37 + and parts.hostname is not None 38 + and parts.hostname == parts.netloc 39 + and parts.username is None 40 + and parts.password is None 41 + and parts.port is None 42 + ): 43 + return False 44 + segments = parts.hostname.split(".") 45 + if not ( 46 + len(segments) >= 2 47 + and segments[-1] not in ["local", "arpa", "internal", "localhost"] 48 + ): 49 + return False 50 + if segments[-1].isdigit(): 51 + return False 52 + return True 53 + 54 + 55 + def is_valid_authserver_meta(obj, url): 56 + """Validate authorization server metadata against atproto requirements.""" 57 + from urllib.parse import urlparse 58 + 59 + fetch_url = urlparse(url) 60 + issuer_url = urlparse(obj["issuer"]) 61 + assert issuer_url.hostname == fetch_url.hostname 62 + assert issuer_url.scheme == "https" 63 + assert "code" in obj["response_types_supported"] 64 + assert "authorization_code" in obj["grant_types_supported"] 65 + assert "refresh_token" in obj["grant_types_supported"] 66 + assert "S256" in obj["code_challenge_methods_supported"] 67 + assert "private_key_jwt" in obj["token_endpoint_auth_methods_supported"] 68 + assert "ES256" in obj["token_endpoint_auth_signing_alg_values_supported"] 69 + assert "atproto" in obj["scopes_supported"] 70 + assert obj["authorization_response_iss_parameter_supported"] is True 71 + assert obj["pushed_authorization_request_endpoint"] is not None 72 + assert obj["require_pushed_authorization_requests"] is True 73 + assert "ES256" in obj["dpop_signing_alg_values_supported"] 74 + assert obj["client_id_metadata_document_supported"] is True 75 + return True 76 + 77 + 78 + def resolve_pds_authserver(url): 79 + """Given a PDS URL, find its authorization server.""" 80 + assert is_safe_url(url) 81 + with hardened_http.get_session() as sess: 82 + resp = sess.get(f"{url}/.well-known/oauth-protected-resource") 83 + resp.raise_for_status() 84 + assert resp.status_code == 200 85 + return resp.json()["authorization_servers"][0] 86 + 87 + 88 + def fetch_authserver_meta(url): 89 + """Fetch and validate authorization server metadata.""" 90 + assert is_safe_url(url) 91 + with hardened_http.get_session() as sess: 92 + resp = sess.get(f"{url}/.well-known/oauth-authorization-server") 93 + resp.raise_for_status() 94 + meta = resp.json() 95 + assert is_valid_authserver_meta(meta, url) 96 + return meta 97 + 98 + 99 + def client_assertion_jwt(client_id, authserver_url, client_secret_jwk): 100 + """Create a signed JWT asserting our client identity.""" 101 + return jwt.encode( 102 + {"alg": "ES256", "kid": client_secret_jwk["kid"]}, 103 + { 104 + "iss": client_id, 105 + "sub": client_id, 106 + "aud": authserver_url, 107 + "jti": generate_token(), 108 + "iat": int(time.time()), 109 + "exp": int(time.time()) + 60, 110 + }, 111 + client_secret_jwk, 112 + ).decode("utf-8") 113 + 114 + 115 + def authserver_dpop_jwt(method, url, nonce, dpop_private_jwk): 116 + """Create a DPoP proof JWT for auth server requests.""" 117 + dpop_pub_jwk = json.loads(dpop_private_jwk.as_json(is_private=False)) 118 + body = { 119 + "jti": generate_token(), 120 + "htm": method, 121 + "htu": url, 122 + "iat": int(time.time()), 123 + "exp": int(time.time()) + 30, 124 + } 125 + if nonce: 126 + body["nonce"] = nonce 127 + return jwt.encode( 128 + {"typ": "dpop+jwt", "alg": "ES256", "jwk": dpop_pub_jwk}, 129 + body, 130 + dpop_private_jwk, 131 + ).decode("utf-8") 132 + 133 + 134 + def _parse_www_authenticate(data): 135 + scheme, _, params = data.partition(" ") 136 + items = urllib.request.parse_http_list(params) 137 + opts = urllib.request.parse_keqv_list(items) 138 + return scheme, opts 139 + 140 + 141 + def is_use_dpop_nonce_error_response(resp): 142 + """Check if a response is asking us to retry with a new DPoP nonce.""" 143 + if resp.status_code not in [400, 401]: 144 + return False 145 + www_authenticate = resp.headers.get("WWW-Authenticate") 146 + if www_authenticate: 147 + try: 148 + scheme, params = _parse_www_authenticate(www_authenticate) 149 + if scheme.lower() == "dpop" and params.get("error") == "use_dpop_nonce": 150 + return True 151 + except Exception: 152 + pass 153 + try: 154 + json_body = resp.json() 155 + if isinstance(json_body, dict) and json_body.get("error") == "use_dpop_nonce": 156 + return True 157 + except Exception: 158 + pass 159 + return False 160 + 161 + 162 + def auth_server_post( 163 + authserver_url, 164 + client_id, 165 + client_secret_jwk, 166 + dpop_private_jwk, 167 + dpop_authserver_nonce, 168 + post_url, 169 + post_data, 170 + ) -> Tuple[str, Response]: 171 + """POST to auth server with client assertion and DPoP, handling nonce rotation.""" 172 + client_assertion = client_assertion_jwt( 173 + client_id, authserver_url, client_secret_jwk 174 + ) 175 + post_data |= { 176 + "client_id": client_id, 177 + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 178 + "client_assertion": client_assertion, 179 + } 180 + dpop_proof = authserver_dpop_jwt( 181 + "POST", post_url, dpop_authserver_nonce, dpop_private_jwk 182 + ) 183 + 184 + assert is_safe_url(post_url) 185 + with hardened_http.get_session() as sess: 186 + resp = sess.post(post_url, data=post_data, headers={"DPoP": dpop_proof}) 187 + 188 + if is_use_dpop_nonce_error_response(resp): 189 + dpop_authserver_nonce = resp.headers["DPoP-Nonce"] 190 + dpop_proof = authserver_dpop_jwt( 191 + "POST", post_url, dpop_authserver_nonce, dpop_private_jwk 192 + ) 193 + with hardened_http.get_session() as sess: 194 + resp = sess.post(post_url, data=post_data, headers={"DPoP": dpop_proof}) 195 + 196 + return dpop_authserver_nonce, resp 197 + 198 + 199 + def send_par_auth_request( 200 + authserver_url, 201 + authserver_meta, 202 + login_hint, 203 + client_id, 204 + redirect_uri, 205 + scope, 206 + client_secret_jwk, 207 + dpop_private_jwk, 208 + ) -> Tuple[str, str, str, Any]: 209 + """Send a Pushed Authorization Request. Returns (pkce_verifier, state, dpop_nonce, response).""" 210 + par_url = authserver_meta["pushed_authorization_request_endpoint"] 211 + state = generate_token() 212 + pkce_verifier = generate_token(48) 213 + code_challenge = create_s256_code_challenge(pkce_verifier) 214 + 215 + par_body = { 216 + "response_type": "code", 217 + "code_challenge": code_challenge, 218 + "code_challenge_method": "S256", 219 + "state": state, 220 + "redirect_uri": redirect_uri, 221 + "scope": scope, 222 + } 223 + if login_hint: 224 + par_body["login_hint"] = login_hint 225 + 226 + assert is_safe_url(par_url) 227 + dpop_authserver_nonce, resp = auth_server_post( 228 + authserver_url=authserver_url, 229 + client_id=client_id, 230 + client_secret_jwk=client_secret_jwk, 231 + dpop_private_jwk=dpop_private_jwk, 232 + dpop_authserver_nonce="", 233 + post_url=par_url, 234 + post_data=par_body, 235 + ) 236 + 237 + return pkce_verifier, state, dpop_authserver_nonce, resp 238 + 239 + 240 + def initial_token_request( 241 + auth_request, code, client_id, redirect_uri, client_secret_jwk 242 + ): 243 + """Exchange authorization code for tokens. Returns (token_body, dpop_nonce).""" 244 + authserver_url = auth_request["authserver_iss"] 245 + authserver_meta = fetch_authserver_meta(authserver_url) 246 + 247 + token_url = authserver_meta["token_endpoint"] 248 + dpop_private_jwk = JsonWebKey.import_key( 249 + json.loads(auth_request["dpop_private_jwk"]) 250 + ) 251 + 252 + params = { 253 + "redirect_uri": redirect_uri, 254 + "grant_type": "authorization_code", 255 + "code": code, 256 + "code_verifier": auth_request["pkce_verifier"], 257 + } 258 + 259 + assert is_safe_url(token_url) 260 + dpop_authserver_nonce, resp = auth_server_post( 261 + authserver_url=authserver_url, 262 + client_id=client_id, 263 + client_secret_jwk=client_secret_jwk, 264 + dpop_private_jwk=dpop_private_jwk, 265 + dpop_authserver_nonce=auth_request["dpop_authserver_nonce"], 266 + post_url=token_url, 267 + post_data=params, 268 + ) 269 + 270 + resp.raise_for_status() 271 + return resp.json(), dpop_authserver_nonce 272 + 273 + 274 + def refresh_token_request(user, client_id, client_secret_jwk): 275 + """Refresh an access token. Returns (token_body, dpop_nonce).""" 276 + authserver_url = user["authserver_iss"] 277 + authserver_meta = fetch_authserver_meta(authserver_url) 278 + 279 + token_url = authserver_meta["token_endpoint"] 280 + dpop_private_jwk = JsonWebKey.import_key(json.loads(user["dpop_private_jwk"])) 281 + 282 + params = { 283 + "grant_type": "refresh_token", 284 + "refresh_token": user["refresh_token"], 285 + } 286 + 287 + assert is_safe_url(token_url) 288 + dpop_authserver_nonce, resp = auth_server_post( 289 + authserver_url=authserver_url, 290 + client_id=client_id, 291 + client_secret_jwk=client_secret_jwk, 292 + dpop_private_jwk=dpop_private_jwk, 293 + dpop_authserver_nonce=user["dpop_authserver_nonce"], 294 + post_url=token_url, 295 + post_data=params, 296 + ) 297 + 298 + resp.raise_for_status() 299 + return resp.json(), dpop_authserver_nonce 300 + 301 + 302 + def revoke_token_request(user, client_id, client_secret_jwk): 303 + """Revoke access and refresh tokens.""" 304 + authserver_url = user["authserver_iss"] 305 + authserver_meta = fetch_authserver_meta(authserver_url) 306 + 307 + dpop_private_jwk = JsonWebKey.import_key(json.loads(user["dpop_private_jwk"])) 308 + dpop_authserver_nonce = user["dpop_authserver_nonce"] 309 + 310 + revoke_url = authserver_meta.get("revocation_endpoint") 311 + if not revoke_url: 312 + return 313 + 314 + assert is_safe_url(revoke_url) 315 + for token_type in ["access_token", "refresh_token"]: 316 + dpop_authserver_nonce, resp = auth_server_post( 317 + authserver_url=authserver_url, 318 + client_id=client_id, 319 + client_secret_jwk=client_secret_jwk, 320 + dpop_private_jwk=dpop_private_jwk, 321 + dpop_authserver_nonce=dpop_authserver_nonce, 322 + post_url=revoke_url, 323 + post_data={ 324 + "token": user[token_type], 325 + "token_type_hint": token_type, 326 + }, 327 + ) 328 + resp.raise_for_status() 329 + 330 + 331 + def pds_authed_req(method, url, user, db, body=None): 332 + """Make an authenticated request to a user's PDS with DPoP.""" 333 + dpop_private_jwk = JsonWebKey.import_key(json.loads(user["dpop_private_jwk"])) 334 + dpop_pds_nonce = user["dpop_pds_nonce"] or "" 335 + access_token = user["access_token"] 336 + 337 + resp = None 338 + for _ in range(2): 339 + dpop_pub_jwk = json.loads(dpop_private_jwk.as_json(is_private=False)) 340 + dpop_body = { 341 + "iat": int(time.time()), 342 + "exp": int(time.time()) + 10, 343 + "jti": generate_token(), 344 + "htm": method, 345 + "htu": url, 346 + "ath": create_s256_code_challenge(access_token), 347 + } 348 + if dpop_pds_nonce: 349 + dpop_body["nonce"] = dpop_pds_nonce 350 + dpop_jwt = jwt.encode( 351 + {"typ": "dpop+jwt", "alg": "ES256", "jwk": dpop_pub_jwk}, 352 + dpop_body, 353 + dpop_private_jwk, 354 + ).decode("utf-8") 355 + 356 + with hardened_http.get_session() as sess: 357 + if method == "GET": 358 + resp = sess.get( 359 + url, 360 + headers={"Authorization": f"DPoP {access_token}", "DPoP": dpop_jwt}, 361 + ) 362 + else: 363 + resp = sess.post( 364 + url, 365 + headers={"Authorization": f"DPoP {access_token}", "DPoP": dpop_jwt}, 366 + json=body, 367 + ) 368 + 369 + if is_use_dpop_nonce_error_response(resp): 370 + dpop_pds_nonce = resp.headers["DPoP-Nonce"] 371 + cur = db.cursor() 372 + cur.execute( 373 + "UPDATE oauth_session SET dpop_pds_nonce = ? WHERE did = ?;", 374 + [dpop_pds_nonce, user["did"]], 375 + ) 376 + db.commit() 377 + cur.close() 378 + continue 379 + break 380 + 381 + return resp
+36
config.py
··· 1 + import json 2 + import os 3 + import time 4 + from pathlib import Path 5 + 6 + from authlib.jose import JsonWebKey 7 + from flask import Flask 8 + 9 + 10 + def _generate_flask_secret() -> str: 11 + return os.urandom(32).hex() 12 + 13 + 14 + def _generate_client_jwk() -> str: 15 + key = JsonWebKey.generate_key("EC", "P-256", is_private=True) 16 + key_dict = json.loads(key.as_json(is_private=True)) 17 + key_dict["kid"] = f"morsel-{int(time.time())}" 18 + return json.dumps(key_dict) 19 + 20 + 21 + def load_config(app: Flask, data_dir: str = ".") -> None: 22 + """Load or generate config. Persists secrets to a file so they survive restarts.""" 23 + secrets_path = Path(data_dir) / "secrets.json" 24 + 25 + if secrets_path.exists(): 26 + secrets = json.loads(secrets_path.read_text()) 27 + else: 28 + secrets = { 29 + "flask_secret_key": _generate_flask_secret(), 30 + "client_secret_jwk": _generate_client_jwk(), 31 + } 32 + secrets_path.write_text(json.dumps(secrets, indent=2)) 33 + print(f"Generated new secrets at {secrets_path}") 34 + 35 + app.secret_key = secrets["flask_secret_key"] 36 + app.config["CLIENT_SECRET_JWK"] = secrets["client_secret_jwk"]
+11
docker-compose.yml
··· 1 + services: 2 + morsels: 3 + build: . 4 + ports: 5 + - "8000:8000" 6 + volumes: 7 + - morsel-data:/data 8 + restart: unless-stopped 9 + 10 + volumes: 11 + morsel-data:
+211
identity.py
··· 1 + import time 2 + from concurrent.futures import ThreadPoolExecutor 3 + 4 + import requests 5 + from atproto_identity.resolver import IdResolver 6 + 7 + CONSTELLATION_URL = "https://constellation.microcosm.blue" 8 + SLINGSHOT_URL = "https://slingshot.microcosm.blue" 9 + UFOS_API_URL = "https://ufos-api.microcosm.blue" 10 + 11 + # Simple timed caches 12 + _identity_cache: dict[str, tuple[tuple[str | None, str | None], float]] = {} 13 + _identity_ttl = 3600 14 + _profile_cache: dict[str, tuple[dict[str, str | None], float]] = {} 15 + _profile_ttl = 3600 16 + _recent_bites_cache: tuple[list[dict[str, str | None]], float] | None = None 17 + _recent_bites_ttl = 60 18 + 19 + 20 + def resolve_did(identifier: str) -> str | None: 21 + """Resolve a handle to a DID. Returns the DID, or None if resolution fails.""" 22 + if identifier.startswith("did:"): 23 + return identifier 24 + resolver = IdResolver() 25 + return resolver.handle.resolve(identifier) 26 + 27 + 28 + def resolve_identity(did: str) -> tuple[str | None, str | None]: 29 + """Resolve a DID to its handle and PDS URL.""" 30 + now = time.time() 31 + if did in _identity_cache: 32 + result, ts = _identity_cache[did] 33 + if now - ts < _identity_ttl: 34 + return result 35 + 36 + resolver = IdResolver() 37 + did_doc = resolver.did.resolve(did) 38 + 39 + if did_doc is None: 40 + _identity_cache[did] = ((None, None), now) 41 + return None, None 42 + 43 + handle = None 44 + for aka in did_doc.also_known_as: # type: ignore[union-attr] 45 + if aka.startswith("at://"): 46 + handle = aka[5:] 47 + break 48 + 49 + pds_url = None 50 + for service in did_doc.service: # type: ignore[union-attr] 51 + if service.id == "#atproto_pds": 52 + pds_url = service.service_endpoint 53 + break 54 + 55 + _identity_cache[did] = ((handle, pds_url), now) 56 + return handle, pds_url 57 + 58 + 59 + def fetch_profile(did: str, pds_url: str) -> dict[str, str | None]: 60 + """Fetch a user's Bluesky profile via Slingshot.""" 61 + now = time.time() 62 + if did in _profile_cache: 63 + result, ts = _profile_cache[did] 64 + if now - ts < _profile_ttl: 65 + return result 66 + 67 + try: 68 + resp = requests.get( 69 + f"{SLINGSHOT_URL}/xrpc/com.atproto.repo.getRecord", 70 + params={ 71 + "repo": did, 72 + "collection": "app.bsky.actor.profile", 73 + "rkey": "self", 74 + }, 75 + timeout=5, 76 + ) 77 + if resp.status_code != 200: 78 + return {} 79 + value = resp.json().get("value", {}) 80 + except requests.RequestException, ValueError: 81 + return {} 82 + 83 + avatar_blob_url = None 84 + avatar = value.get("avatar") 85 + if avatar and isinstance(avatar, dict): 86 + cid = avatar.get("ref", {}).get("$link") 87 + if cid: 88 + avatar_blob_url = ( 89 + f"{pds_url}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}" 90 + ) 91 + 92 + profile = { 93 + "display_name": value.get("displayName"), 94 + "description": value.get("description"), 95 + "pronouns": value.get("pronouns"), 96 + "avatar_url": f"/avatar/{did}" if avatar_blob_url else None, 97 + "avatar_blob_url": avatar_blob_url, 98 + } 99 + _profile_cache[did] = (profile, now) 100 + return profile 101 + 102 + 103 + def fetch_recent_bites(limit: int = 5) -> list[dict[str, str | None]]: 104 + """Fetch the most recent bites network-wide from UFOs.""" 105 + global _recent_bites_cache 106 + now = time.time() 107 + if _recent_bites_cache is not None: 108 + cached, ts = _recent_bites_cache 109 + if now - ts < _recent_bites_ttl: 110 + return cached[:limit] 111 + 112 + try: 113 + resp = requests.get( 114 + f"{UFOS_API_URL}/records", 115 + params={"collection": "blue.morsels.bite", "limit": limit}, 116 + timeout=5, 117 + ) 118 + if resp.status_code != 200: 119 + return [] 120 + raw = resp.json() 121 + except requests.RequestException, ValueError: 122 + return [] 123 + 124 + # Resolve all identities in parallel 125 + dids = [item.get("did", "") for item in raw] 126 + unique_dids = list(set(d for d in dids if d)) 127 + with ThreadPoolExecutor(max_workers=5) as pool: 128 + results = dict(zip(unique_dids, pool.map(resolve_identity, unique_dids))) 129 + 130 + bites = [] 131 + for item in raw: 132 + record = item.get("record", {}) 133 + did = item.get("did", "") 134 + handle, _ = results.get(did, (None, None)) 135 + try: 136 + bites.append( 137 + { 138 + "did": did, 139 + "handle": handle, 140 + "rkey": item.get("rkey", ""), 141 + "title": record["title"], 142 + "content": record["content"], 143 + "created_at": record.get("createdAt", ""), 144 + } 145 + ) 146 + except KeyError, TypeError: 147 + continue 148 + 149 + _recent_bites_cache = (bites, now) 150 + return bites 151 + 152 + 153 + def fetch_replies(did: str, rkey: str) -> list[dict[str, str]]: 154 + """Fetch reply backlinks from Constellation.""" 155 + at_uri = f"at://{did}/blue.morsels.bite/{rkey}" 156 + 157 + try: 158 + resp = requests.get( 159 + f"{CONSTELLATION_URL}/xrpc/blue.microcosm.links.getBacklinks", 160 + params={ 161 + "subject": at_uri, 162 + "source": "blue.morsels.reply:subject.uri", 163 + "limit": 100, 164 + }, 165 + timeout=5, 166 + ) 167 + if resp.status_code != 200: 168 + return [] 169 + return resp.json().get("records", []) 170 + except requests.RequestException, ValueError: 171 + return [] 172 + 173 + 174 + def hydrate_replies(records: list[dict[str, str]]) -> list[dict[str, str | None]]: 175 + """Fetch reply record contents from Slingshot.""" 176 + replies: list[dict[str, str | None]] = [] 177 + for record in records: 178 + did = record.get("did") 179 + rkey = record.get("rkey") 180 + if not did or not rkey: 181 + continue 182 + 183 + try: 184 + resp = requests.get( 185 + f"{SLINGSHOT_URL}/xrpc/com.atproto.repo.getRecord", 186 + params={ 187 + "repo": did, 188 + "collection": "blue.morsels.reply", 189 + "rkey": rkey, 190 + }, 191 + timeout=5, 192 + ) 193 + if resp.status_code != 200: 194 + continue 195 + value = resp.json().get("value", {}) 196 + except requests.RequestException, ValueError: 197 + continue 198 + 199 + handle, _ = resolve_identity(did) 200 + 201 + replies.append( 202 + { 203 + "did": did, 204 + "handle": handle, 205 + "rkey": rkey, 206 + "text": value.get("text", ""), 207 + "created_at": value.get("createdAt", ""), 208 + } 209 + ) 210 + 211 + return replies
+778
main.py
··· 1 + import json 2 + 3 + # ============================================================================= 4 + # App setup 5 + # ============================================================================= 6 + import os 7 + import secrets 8 + import sqlite3 9 + import time 10 + from datetime import datetime 11 + from typing import Any 12 + from urllib.parse import urlencode, urlparse 13 + 14 + import regex 15 + import requests 16 + from atproto import Client, models 17 + from atproto_client.exceptions import BadRequestError, NetworkError 18 + from atproto_identity.resolver import IdResolver 19 + from authlib.jose import JsonWebKey 20 + from flask import ( 21 + Flask, 22 + Response, 23 + abort, 24 + flash, 25 + g, 26 + jsonify, 27 + redirect, 28 + render_template, 29 + request, 30 + session, 31 + url_for, 32 + ) 33 + from pygments import highlight 34 + from pygments.formatters import HtmlFormatter 35 + from pygments.lexers import TextLexer, guess_lexer 36 + from werkzeug.exceptions import HTTPException 37 + from werkzeug.wrappers import Response as WerkzeugResponse 38 + 39 + from atproto_oauth import ( 40 + fetch_authserver_meta, 41 + initial_token_request, 42 + is_safe_url, 43 + pds_authed_req, 44 + refresh_token_request, 45 + resolve_pds_authserver, 46 + revoke_token_request, 47 + send_par_auth_request, 48 + ) 49 + from config import load_config 50 + from identity import ( 51 + fetch_profile, 52 + fetch_recent_bites, 53 + fetch_replies, 54 + hydrate_replies, 55 + resolve_did, 56 + resolve_identity, 57 + ) 58 + 59 + app = Flask(__name__) 60 + DATA_DIR = os.environ.get("MORSEL_DATA_DIR", ".") 61 + load_config(app, data_dir=DATA_DIR) 62 + 63 + CLIENT_SECRET_JWK = JsonWebKey.import_key(json.loads(app.config["CLIENT_SECRET_JWK"])) 64 + CLIENT_PUB_JWK = json.loads(CLIENT_SECRET_JWK.as_json(is_private=False)) 65 + assert "d" not in CLIENT_PUB_JWK 66 + 67 + OAUTH_SCOPE = "atproto repo:blue.morsels.bite repo:blue.morsels.reply" 68 + COLLECTION = "blue.morsels.bite" 69 + SLINGSHOT_URL = "https://slingshot.microcosm.blue" 70 + 71 + _avatar_cache: dict[str, tuple[bytes, str, float]] = {} 72 + AVATAR_TTL = 1800 # 30mins 73 + 74 + # ============================================================================= 75 + # Database 76 + # ============================================================================= 77 + 78 + 79 + def get_db() -> sqlite3.Connection: 80 + if "db" not in g: 81 + db_path = app.config.get("DATABASE_URL", os.path.join(DATA_DIR, "morsel.db")) 82 + g.db = sqlite3.connect(db_path) 83 + g.db.execute("PRAGMA journal_mode=WAL") 84 + g.db.row_factory = sqlite3.Row 85 + return g.db 86 + 87 + 88 + def query_db( 89 + query: str, args: tuple | list = (), one: bool = False 90 + ) -> sqlite3.Row | list[sqlite3.Row] | None: 91 + db = get_db() 92 + cur = db.cursor() 93 + cur.execute(query, args) 94 + rv = cur.fetchall() 95 + db.commit() 96 + cur.close() 97 + return (rv[0] if rv else None) if one else rv 98 + 99 + 100 + with app.app_context(): 101 + db = get_db() 102 + with app.open_resource("schema.sql", mode="r") as f: 103 + db.cursor().executescript(f.read()) 104 + db.commit() 105 + 106 + 107 + # ============================================================================= 108 + # Helpers 109 + # ============================================================================= 110 + 111 + 112 + @app.route("/avatar/<path:did>") 113 + def avatar_proxy(did: str) -> WerkzeugResponse: 114 + now = time.time() 115 + 116 + if did in _avatar_cache: 117 + data, content_type, ts = _avatar_cache[did] 118 + if now - ts < AVATAR_TTL: 119 + return Response(data, mimetype=content_type) 120 + 121 + # Resolve PDS to find avatar 122 + handle, pds_url = resolve_identity(did) 123 + if pds_url is None: 124 + return Response(status=404) 125 + 126 + profile = fetch_profile(did, pds_url) 127 + avatar_url = profile.get("avatar_blob_url") 128 + if not avatar_url: 129 + return Response(status=404) 130 + 131 + try: 132 + resp = requests.get(avatar_url, timeout=5) 133 + resp.raise_for_status() 134 + except requests.RequestException, ValueError: 135 + return Response(status=502) 136 + 137 + content_type = resp.headers.get("Content-Type", "image/jpeg") 138 + _avatar_cache[did] = (resp.content, content_type, now) 139 + 140 + return Response(resp.content, mimetype=content_type) 141 + 142 + 143 + def compute_client_id(url_root: str) -> tuple[str, str]: 144 + parsed = urlparse(url_root) 145 + if parsed.hostname in ["localhost", "127.0.0.1"]: 146 + redirect_uri = f"http://127.0.0.1:{parsed.port}/oauth/callback" 147 + client_id = "http://localhost?" + urlencode( 148 + {"redirect_uri": redirect_uri, "scope": OAUTH_SCOPE} 149 + ) 150 + else: 151 + app_url = url_root.replace("http://", "https://") 152 + redirect_uri = f"{app_url}oauth/callback" 153 + client_id = f"{app_url}oauth-client-metadata.json" 154 + return client_id, redirect_uri 155 + 156 + 157 + def highlight_code(content: str | None) -> str: 158 + lexer = guess_lexer(content) if content else TextLexer() 159 + formatter = HtmlFormatter(nowrap=True) 160 + return highlight(content or "", lexer, formatter).rstrip("\n") 161 + 162 + 163 + def require_identity( 164 + identifier: str, redirect_endpoint: str, **redirect_kwargs: Any 165 + ) -> tuple[str, str | None, str, dict] | WerkzeugResponse: 166 + """Resolve an identifier to a DID, redirecting handles to canonical DID URLs. 167 + 168 + Returns (did, handle, pds_url, profile) or redirects/aborts. 169 + Callers must check the return — if it's a Response (redirect), return it directly. 170 + """ 171 + did = resolve_did(identifier) 172 + if did is None: 173 + abort(404, "User not found") 174 + if did != identifier: 175 + return redirect(url_for(redirect_endpoint, identifier=did, **redirect_kwargs)) 176 + 177 + handle, pds_url = resolve_identity(did) 178 + if handle is None and pds_url is None: 179 + abort(404, "User not found.") 180 + if pds_url is None: 181 + abort(502, "Could not reach this user's server.") 182 + 183 + profile = fetch_profile(did, pds_url) 184 + return did, handle, pds_url, profile 185 + 186 + 187 + def fetch_bites(pds_url: str, did: str, limit: int = 100) -> list[dict[str, str]]: 188 + """Fetch bite records from a user's PDS. Returns a list of dicts.""" 189 + try: 190 + client = Client(pds_url) 191 + response = client.com.atproto.repo.list_records( 192 + models.ComAtprotoRepoListRecords.Params( 193 + repo=did, 194 + collection=COLLECTION, 195 + limit=limit, 196 + ) 197 + ) 198 + except BadRequestError: 199 + abort(404, "User not found.") 200 + except NetworkError: 201 + abort(502, "Could not reach this user's server.") 202 + except Exception: 203 + abort(500, "Something went wrong loading bites.") 204 + 205 + bites = [] 206 + for record in response.records: 207 + try: 208 + bites.append( 209 + { 210 + "rkey": record.uri.split("/")[-1], 211 + "title": record.value["title"], 212 + "content": record.value["content"], 213 + "created_at": record.value["createdAt"], 214 + } 215 + ) 216 + except KeyError, TypeError: 217 + continue 218 + return bites 219 + 220 + 221 + def authed_pds_request( 222 + method: str, path: str, body: dict | None = None 223 + ) -> WerkzeugResponse | requests.Response | None: 224 + """Make an authenticated request to the logged-in user's PDS, refreshing tokens if needed. 225 + 226 + Returns the response, or redirects to login on auth failure. 227 + """ 228 + pds_url = g.user["pds_url"] 229 + did = g.user["did"] 230 + url = f"{pds_url}/xrpc/{path}" 231 + 232 + resp = pds_authed_req(method, url, user=g.user, db=get_db(), body=body) 233 + 234 + if resp.status_code == 401: # type: ignore[union-attr] 235 + client_id, _ = compute_client_id(request.url_root) 236 + try: 237 + tokens, dpop_nonce = refresh_token_request( 238 + g.user, 239 + client_id, 240 + CLIENT_SECRET_JWK, 241 + ) 242 + query_db( 243 + "UPDATE oauth_session SET access_token = ?, refresh_token = ?, dpop_authserver_nonce = ? WHERE did = ?;", 244 + [tokens["access_token"], tokens["refresh_token"], dpop_nonce, did], 245 + ) 246 + g.user = query_db( 247 + "SELECT * FROM oauth_session WHERE did = ?", 248 + [did], 249 + one=True, 250 + ) 251 + resp = pds_authed_req(method, url, user=g.user, db=get_db(), body=body) 252 + except Exception: 253 + flash("Session expired, please log in again", "error") 254 + return redirect(url_for("oauth_login")) 255 + 256 + return resp 257 + 258 + 259 + def delete_record( 260 + collection: str, record_rkey: str 261 + ) -> WerkzeugResponse | requests.Response | None: 262 + """Delete a record from the logged-in user's repo.""" 263 + body = { 264 + "repo": g.user["did"], 265 + "collection": collection, 266 + "rkey": record_rkey, 267 + } 268 + return authed_pds_request("POST", "com.atproto.repo.deleteRecord", body=body) 269 + 270 + 271 + def check_csrf() -> None: 272 + token = session.get("csrf_token") 273 + submitted = request.form.get("csrf_token") 274 + if not token or token != submitted: 275 + abort(400, "Invalid or missing security token. Please try again.") 276 + 277 + 278 + # ============================================================================= 279 + # Hooks and filters 280 + # ============================================================================= 281 + 282 + 283 + @app.teardown_appcontext 284 + def close_db(exception: BaseException | None) -> None: 285 + db = g.pop("db", None) 286 + if db is not None: 287 + db.close() 288 + 289 + 290 + @app.before_request 291 + def ensure_csrf_token() -> None: 292 + if "csrf_token" not in session: 293 + session["csrf_token"] = secrets.token_hex(32) 294 + 295 + 296 + @app.before_request 297 + def load_logged_in_user() -> None: 298 + user_did = session.get("user_did") 299 + if user_did is None: 300 + g.user = None 301 + else: 302 + g.user = query_db( 303 + "SELECT * FROM oauth_session WHERE did = ?", [user_did], one=True 304 + ) 305 + 306 + 307 + @app.template_filter("humandate") 308 + def humandate_filter(value: str) -> str: 309 + try: 310 + dt = datetime.fromisoformat(value) 311 + return dt.strftime("%b %-d, %Y at %-I:%M %p").lower() 312 + except Exception: 313 + return value 314 + 315 + 316 + # ============================================================================= 317 + # Error handling 318 + # ============================================================================= 319 + 320 + 321 + @app.errorhandler(403) 322 + def forbidden(e: HTTPException) -> tuple[str, int]: 323 + message = ( 324 + e.description 325 + if e.description != "Forbidden" 326 + else "You don't have permission to do that." 327 + ) 328 + return render_template("error.html", code=403, message=message), 403 329 + 330 + 331 + @app.errorhandler(400) 332 + def bad_request(e: HTTPException) -> tuple[str, int]: 333 + message = e.description if e.description != "Bad Request" else "Bad request." 334 + return render_template("error.html", code=400, message=message), 400 335 + 336 + 337 + @app.errorhandler(401) 338 + def unauthorized(e: HTTPException) -> tuple[str, int]: 339 + message = ( 340 + e.description if e.description != "Unauthorized" else "You need to log in." 341 + ) 342 + return render_template("error.html", code=401, message=message), 401 343 + 344 + 345 + @app.errorhandler(404) 346 + def not_found(e: HTTPException) -> tuple[str, int]: 347 + message = ( 348 + e.description if e.description != "Not Found" else "That page doesn't exist." 349 + ) 350 + return render_template("error.html", code=404, message=message), 404 351 + 352 + 353 + @app.errorhandler(500) 354 + def internal_error(e: HTTPException) -> tuple[str, int]: 355 + return render_template( 356 + "error.html", code=500, message="Something went wrong on our end." 357 + ), 500 358 + 359 + 360 + @app.errorhandler(502) 361 + def bad_gateway(e: HTTPException) -> tuple[str, int]: 362 + message = ( 363 + e.description 364 + if e.description != "Bad Gateway" 365 + else "Couldn't reach the upstream server." 366 + ) 367 + return render_template("error.html", code=502, message=message), 502 368 + 369 + 370 + # ============================================================================= 371 + # OAuth metadata 372 + # ============================================================================= 373 + 374 + 375 + @app.route("/oauth-client-metadata.json") 376 + def oauth_client_metadata() -> WerkzeugResponse: 377 + app_url = request.url_root.replace("http://", "https://") 378 + client_id = f"{app_url}oauth-client-metadata.json" 379 + return jsonify( 380 + { 381 + "client_id": client_id, 382 + "dpop_bound_access_tokens": True, 383 + "application_type": "web", 384 + "redirect_uris": [f"{app_url}oauth/callback"], 385 + "grant_types": ["authorization_code", "refresh_token"], 386 + "response_types": ["code"], 387 + "scope": OAUTH_SCOPE, 388 + "token_endpoint_auth_method": "private_key_jwt", 389 + "token_endpoint_auth_signing_alg": "ES256", 390 + "jwks_uri": f"{app_url}oauth/jwks.json", 391 + "client_name": "Morsels", 392 + "client_uri": app_url, 393 + } 394 + ) 395 + 396 + 397 + @app.route("/oauth/jwks.json") 398 + def oauth_jwks() -> WerkzeugResponse: 399 + return jsonify({"keys": [CLIENT_PUB_JWK]}) 400 + 401 + 402 + @app.route("/pygments.css") 403 + def pygments_css() -> WerkzeugResponse: 404 + css = HtmlFormatter(style="default").get_style_defs() 405 + return Response(css, mimetype="text/css") 406 + 407 + 408 + # ============================================================================= 409 + # OAuth flow 410 + # ============================================================================= 411 + 412 + 413 + @app.route("/oauth/login", methods=("GET", "POST")) 414 + def oauth_login() -> WerkzeugResponse | str: 415 + if request.method != "POST": 416 + return redirect(url_for("index")) 417 + 418 + check_csrf() 419 + username = request.form["username"] 420 + username = regex.sub(r"[\p{C}]", "", username) 421 + if username.startswith("@"): 422 + username = username[1:] 423 + 424 + did = resolve_did(username) 425 + if did is None: 426 + flash("Could not find that account. Check your handle and try again.", "error") 427 + return redirect(url_for("index")) 428 + 429 + handle, pds_url = resolve_identity(did) 430 + if pds_url is None: 431 + flash("Could not reach your server. Try again later.", "error") 432 + return redirect(url_for("index")) 433 + 434 + try: 435 + authserver_url = resolve_pds_authserver(pds_url) 436 + except Exception: 437 + flash("Could not connect to your login provider. Try again later.", "error") 438 + return redirect(url_for("index")) 439 + 440 + try: 441 + authserver_meta = fetch_authserver_meta(authserver_url) 442 + except Exception: 443 + flash("Could not connect to your login provider. Try again later.", "error") 444 + return redirect(url_for("index")) 445 + 446 + dpop_private_jwk = JsonWebKey.generate_key("EC", "P-256", is_private=True) 447 + client_id, redirect_uri = compute_client_id(request.url_root) 448 + 449 + pkce_verifier, state, dpop_authserver_nonce, resp = send_par_auth_request( 450 + authserver_url, 451 + authserver_meta, 452 + username, 453 + client_id, 454 + redirect_uri, 455 + OAUTH_SCOPE, 456 + CLIENT_SECRET_JWK, 457 + dpop_private_jwk, 458 + ) 459 + 460 + if resp.status_code != 201: 461 + flash("Login request failed. Try again later.", "error") 462 + return redirect(url_for("index")) 463 + 464 + par_request_uri = resp.json()["request_uri"] 465 + 466 + query_db( 467 + "INSERT INTO oauth_auth_request (state, authserver_iss, did, handle, pds_url, pkce_verifier, scope, dpop_authserver_nonce, dpop_private_jwk) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?);", 468 + [ 469 + state, 470 + authserver_meta["issuer"], 471 + did, 472 + handle, 473 + pds_url, 474 + pkce_verifier, 475 + OAUTH_SCOPE, 476 + dpop_authserver_nonce, 477 + dpop_private_jwk.as_json(is_private=True), 478 + ], 479 + ) 480 + 481 + auth_url = authserver_meta["authorization_endpoint"] 482 + if not is_safe_url(auth_url): 483 + flash("Login failed due to a security issue. Try again later.", "error") 484 + return redirect(url_for("index")) 485 + qparam = urlencode({"client_id": client_id, "request_uri": par_request_uri}) 486 + return redirect(f"{auth_url}?{qparam}") 487 + 488 + 489 + @app.route("/oauth/callback") 490 + def oauth_callback() -> WerkzeugResponse: 491 + if request.args.get("error"): 492 + flash("Login was denied or failed. Please try again.", "error") 493 + return redirect(url_for("index")) 494 + 495 + state = request.args["state"] 496 + authserver_iss = request.args["iss"] 497 + code = request.args["code"] 498 + 499 + row = query_db( 500 + "SELECT * FROM oauth_auth_request WHERE state = ?;", [state], one=True 501 + ) 502 + if row is None: 503 + flash("Login session expired. Please try again.", "error") 504 + return redirect(url_for("index")) 505 + 506 + query_db("DELETE FROM oauth_auth_request WHERE state = ?;", [state]) 507 + 508 + if row["authserver_iss"] != authserver_iss: # type: ignore[index] 509 + flash("Login failed due to a security issue. Please try again.", "error") 510 + return redirect(url_for("index")) 511 + 512 + client_id, redirect_uri = compute_client_id(request.url_root) 513 + tokens, dpop_authserver_nonce = initial_token_request( 514 + row, 515 + code, 516 + client_id, 517 + redirect_uri, 518 + CLIENT_SECRET_JWK, 519 + ) 520 + 521 + if row["did"]: # type: ignore[index] 522 + did, handle, pds_url = row["did"], row["handle"], row["pds_url"] # type: ignore[index] 523 + if tokens["sub"] != did: 524 + flash("Login failed — identity mismatch. Please try again.", "error") 525 + return redirect(url_for("index")) 526 + else: 527 + did = tokens["sub"] 528 + resolver = IdResolver() 529 + did_doc = resolver.did.resolve(did) 530 + handle = None 531 + for aka in did_doc.also_known_as: # type: ignore[union-attr] 532 + if aka.startswith("at://"): 533 + handle = aka[5:] 534 + break 535 + pds_url = None 536 + for svc in did_doc.service: # type: ignore[union-attr] 537 + if svc.id == "#atproto_pds": 538 + pds_url = svc.service_endpoint 539 + break 540 + 541 + query_db( 542 + "INSERT OR REPLACE INTO oauth_session (did, handle, pds_url, authserver_iss, access_token, refresh_token, dpop_authserver_nonce, dpop_private_jwk) VALUES(?, ?, ?, ?, ?, ?, ?, ?);", 543 + [ 544 + did, 545 + handle, 546 + pds_url, 547 + authserver_iss, 548 + tokens["access_token"], 549 + tokens["refresh_token"], 550 + dpop_authserver_nonce, 551 + row["dpop_private_jwk"], # type: ignore[index] 552 + ], 553 + ) 554 + 555 + session["user_did"] = did 556 + session["user_handle"] = handle 557 + return redirect(url_for("index")) 558 + 559 + 560 + @app.route("/oauth/logout") 561 + def oauth_logout() -> WerkzeugResponse: 562 + if g.user: 563 + client_id, _ = compute_client_id(request.url_root) 564 + try: 565 + revoke_token_request(g.user, client_id, CLIENT_SECRET_JWK) 566 + except Exception: 567 + pass 568 + query_db("DELETE FROM oauth_session WHERE did = ?;", [g.user["did"]]) 569 + 570 + session.clear() 571 + return redirect(url_for("index")) 572 + 573 + 574 + # ============================================================================= 575 + # Bite routes 576 + # ============================================================================= 577 + 578 + 579 + @app.route("/") 580 + def index() -> str: 581 + recent = fetch_recent_bites(limit=5) 582 + 583 + if g.user: 584 + return render_template( 585 + "create.html", recent=recent, did=g.user["did"], handle=g.user["handle"] 586 + ) 587 + 588 + return render_template("index.html", recent=recent) 589 + 590 + 591 + @app.route("/b/new", methods=["POST"]) 592 + def create_bite() -> WerkzeugResponse | str: 593 + if not g.user: 594 + return redirect(url_for("oauth_login")) 595 + check_csrf() 596 + 597 + content: str = request.form["content"] 598 + title: str = request.form.get("title", "").strip() or "Untitled" 599 + did: str = g.user["did"] 600 + 601 + body = { 602 + "repo": did, 603 + "collection": COLLECTION, 604 + "record": { 605 + "$type": COLLECTION, 606 + "title": title, 607 + "content": content, 608 + "createdAt": datetime.now().astimezone().isoformat(), 609 + }, 610 + } 611 + 612 + resp = authed_pds_request("POST", "com.atproto.repo.createRecord", body=body) 613 + if isinstance(resp, WerkzeugResponse): 614 + return resp 615 + if resp is None or resp.status_code not in [200, 201]: 616 + abort(500, "Failed to create bite. Please try again.") 617 + 618 + rkey: str = resp.json()["uri"].split("/")[-1] 619 + return redirect(url_for("view_bite", identifier=did, rkey=rkey)) 620 + 621 + 622 + @app.route("/u/<identifier>") 623 + def list_bites(identifier: str) -> WerkzeugResponse | str: 624 + result = require_identity(identifier, "list_bites") 625 + if not isinstance(result, tuple): 626 + return result 627 + did, handle, pds_url, profile = result 628 + 629 + pastes = fetch_bites(pds_url, did) 630 + 631 + return render_template( 632 + "list.html", 633 + pastes=pastes, 634 + did=did, 635 + handle=handle, 636 + profile=profile, 637 + ) 638 + 639 + 640 + @app.route("/b/<path:identifier>/<rkey>") 641 + def view_bite(identifier: str, rkey: str) -> WerkzeugResponse | str: 642 + result = require_identity(identifier, "view_bite", rkey=rkey) 643 + if not isinstance(result, tuple): 644 + return result 645 + did, handle, pds_url, profile = result 646 + 647 + try: 648 + client = Client(SLINGSHOT_URL) 649 + response = client.com.atproto.repo.get_record( 650 + models.ComAtprotoRepoGetRecord.Params( 651 + repo=did, 652 + collection=COLLECTION, 653 + rkey=rkey, 654 + ) 655 + ) 656 + except BadRequestError: 657 + abort(404, "Bite not found.") 658 + except NetworkError: 659 + abort(502, "Could not reach this user's server.") 660 + except Exception: 661 + abort(500, "Something went wrong loading this bite.") 662 + 663 + raw_replies = fetch_replies(did, rkey) 664 + replies = hydrate_replies(raw_replies) 665 + 666 + pending = session.pop("pending_reply", None) 667 + if pending: 668 + pending_did = pending.get("did") 669 + pending_text = pending.get("text") 670 + already_indexed = any( 671 + r["did"] == pending_did and r["text"] == pending_text for r in replies 672 + ) 673 + if not already_indexed: 674 + replies.insert(0, pending) 675 + 676 + try: 677 + title: str = response.value["title"] or "Untitled" 678 + content: str = response.value["content"] or "" 679 + created_at: str = response.value["createdAt"] or "" 680 + except KeyError, TypeError: 681 + abort(500, "This bite has missing or malformed data.") 682 + return "" # unreachable, satisfies type checker 683 + 684 + return render_template( 685 + "view.html", 686 + title=title, 687 + paste_html=highlight_code(content), 688 + paste_raw=content, 689 + paste_id=f"{did}/{rkey}", 690 + did=did, 691 + handle=handle, 692 + profile=profile, 693 + created_at=created_at, 694 + at_uri=f"at://{did}/{COLLECTION}/{rkey}", 695 + cid=response.cid, 696 + replies=replies, 697 + ) 698 + 699 + 700 + @app.route("/b/<path:identifier>/<rkey>/reply", methods=["POST"]) 701 + def create_reply(identifier: str, rkey: str) -> WerkzeugResponse: 702 + if not g.user: 703 + return redirect(url_for("oauth_login")) 704 + check_csrf() 705 + 706 + text: str = request.form["text"].strip() 707 + if not text: 708 + return redirect(url_for("view_bite", identifier=identifier, rkey=rkey)) 709 + 710 + at_uri: str = request.form["at_uri"] 711 + cid: str = request.form["cid"] 712 + 713 + body = { 714 + "repo": g.user["did"], 715 + "collection": "blue.morsels.reply", 716 + "record": { 717 + "$type": "blue.morsels.reply", 718 + "text": text, 719 + "createdAt": datetime.now().astimezone().isoformat(), 720 + "subject": { 721 + "uri": at_uri, 722 + "cid": cid, 723 + }, 724 + }, 725 + } 726 + 727 + resp = authed_pds_request("POST", "com.atproto.repo.createRecord", body=body) 728 + if isinstance(resp, WerkzeugResponse): 729 + return resp 730 + 731 + reply_rkey: str | None = ( 732 + resp.json().get("uri", "").split("/")[-1] 733 + if resp is not None and resp.status_code in [200, 201] 734 + else None 735 + ) 736 + session["pending_reply"] = { 737 + "did": g.user["did"], 738 + "handle": g.user["handle"], 739 + "rkey": reply_rkey, 740 + "text": text, 741 + "created_at": datetime.now().astimezone().isoformat(), 742 + } 743 + return redirect(url_for("view_bite", identifier=identifier, rkey=rkey)) 744 + 745 + 746 + @app.route("/b/<path:identifier>/<rkey>/delete", methods=["POST"]) 747 + def delete_bite(identifier: str, rkey: str) -> WerkzeugResponse: 748 + if not g.user: 749 + return redirect(url_for("oauth_login")) 750 + check_csrf() 751 + 752 + if resolve_did(identifier) != g.user["did"]: 753 + abort(403, "You can only delete your own bites.") 754 + 755 + resp = delete_record(COLLECTION, rkey) 756 + if isinstance(resp, WerkzeugResponse): 757 + return resp 758 + 759 + flash("Bite deleted.") 760 + return redirect(url_for("list_bites", identifier=g.user["did"])) 761 + 762 + 763 + @app.route("/b/<path:identifier>/<rkey>/delete-reply", methods=["POST"]) 764 + def delete_reply(identifier: str, rkey: str) -> WerkzeugResponse: 765 + if not g.user: 766 + return redirect(url_for("oauth_login")) 767 + check_csrf() 768 + 769 + reply_rkey: str | None = request.form.get("reply_rkey") 770 + if not reply_rkey: 771 + abort(400) 772 + 773 + resp = delete_record("blue.morsels.reply", reply_rkey) 774 + if isinstance(resp, WerkzeugResponse): 775 + return resp 776 + 777 + flash("Reply deleted.") 778 + return redirect(url_for("view_bite", identifier=identifier, rkey=rkey))
+15
pyproject.toml
··· 1 + [project] 2 + name = "morsels" 3 + version = "0.1.0" 4 + description = "Add your description here" 5 + readme = "README.md" 6 + requires-python = ">=3.14" 7 + dependencies = [ 8 + "atproto>=0.0.65", 9 + "authlib>=1.6.9", 10 + "flask>=3.1.3", 11 + "gunicorn>=25.3.0", 12 + "pygments>=2.20.0", 13 + "regex>=2026.3.32", 14 + "requests-hardened>=1.2.0", 15 + ]
+28
schema.sql
··· 1 + -- Temporary state during the OAuth authorization flow. 2 + -- Each row represents an in-progress login attempt. 3 + -- Deleted after the callback completes (or on expiry). 4 + CREATE TABLE IF NOT EXISTS oauth_auth_request ( 5 + state TEXT NOT NULL PRIMARY KEY, 6 + authserver_iss TEXT NOT NULL, 7 + did TEXT, 8 + handle TEXT, 9 + pds_url TEXT, 10 + pkce_verifier TEXT NOT NULL, 11 + scope TEXT NOT NULL, 12 + dpop_authserver_nonce TEXT NOT NULL, 13 + dpop_private_jwk TEXT NOT NULL 14 + ); 15 + 16 + -- Active authenticated sessions. 17 + -- One row per logged-in user. Tokens are refreshed in place. 18 + CREATE TABLE IF NOT EXISTS oauth_session ( 19 + did TEXT NOT NULL PRIMARY KEY, 20 + handle TEXT, 21 + pds_url TEXT NOT NULL, 22 + authserver_iss TEXT NOT NULL, 23 + access_token TEXT, 24 + refresh_token TEXT, 25 + dpop_authserver_nonce TEXT NOT NULL, 26 + dpop_pds_nonce TEXT, 27 + dpop_private_jwk TEXT NOT NULL 28 + );
+21
templates/_bite_list.html
··· 1 + {% if pastes %} 2 + <div class="space-y-2"> 3 + {% for paste in pastes %} 4 + <a href="{{ url_for('view_bite', identifier=did, rkey=paste.rkey) }}" 5 + class="block border border-gray-200 rounded-md overflow-hidden hover:border-gray-300 transition-colors"> 6 + <div class="flex items-baseline justify-between gap-3 px-4 py-2.5"> 7 + <span class="text-sm font-medium text-gray-900 truncate">{{ paste.title }}</span> 8 + <span class="text-xs text-gray-400 shrink-0">{{ paste.created_at | humandate }}</span> 9 + </div> 10 + <!-- prettier-ignore --> 11 + <div class="bg-gray-50 border-t border-gray-100 px-4 py-2"> 12 + <p class="text-xs text-gray-500 font-mono truncate">{{ paste.content[:150] | e }}</p> 13 + </div> 14 + </a> 15 + {% endfor %} 16 + </div> 17 + {% else %} 18 + <div class="text-center py-12 text-sm text-gray-400"> 19 + <p>No bites yet.</p> 20 + </div> 21 + {% endif %}
+24
templates/_recent_bites.html
··· 1 + {% if recent %} 2 + <div class="space-y-2"> 3 + {% for bite in recent %} 4 + <a href="{{ url_for('view_bite', identifier=bite.did, rkey=bite.rkey) }}" 5 + class="block border border-gray-200 rounded-md overflow-hidden hover:border-gray-300 transition-colors"> 6 + <div class="relative flex items-center justify-between px-4 py-2.5"> 7 + <span class="flex items-center gap-1.5 hover:underline cursor-pointer js-profile-link" 8 + data-href="{{ url_for('list_bites', identifier=bite.did) }}"> 9 + <img src="/avatar/{{ bite.did }}" alt="" class="w-4 h-4 rounded-full bg-gray-100"> 10 + <span class="text-xs text-amber-600">{{ bite.handle or bite.did }}</span> 11 + </span> 12 + <span class="absolute inset-0 flex items-center justify-center text-sm font-medium text-gray-900 pointer-events-none">{{ bite.title }}</span> 13 + <span class="text-xs text-gray-400">{{ bite.created_at | humandate }}</span> 14 + </div> 15 + <!-- prettier-ignore --> 16 + <div class="bg-gray-50 border-t border-gray-100 px-4 py-2"> 17 + <p class="text-xs text-gray-500 font-mono truncate">{{ bite.content[:150] | e }}</p> 18 + </div> 19 + </a> 20 + {% endfor %} 21 + </div> 22 + {% else %} 23 + <p class="text-center text-sm text-gray-400 py-8">No bites yet.</p> 24 + {% endif %}
+152
templates/base.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <script src="https://cdn.tailwindcss.com"></script> 7 + <link rel="stylesheet" href="/pygments.css"> 8 + <title>{% block title %}morsels{% endblock %}</title> 9 + </head> 10 + <body class="bg-white text-gray-900 min-h-screen flex flex-col antialiased"> 11 + <header class="border-b border-gray-200 sticky top-0 bg-white z-10"> 12 + <div class="max-w-3xl mx-auto px-5 h-12 flex items-center justify-between"> 13 + <a href="/" class="font-bold text-gray-900 no-underline tracking-tight">morsels</a> 14 + <nav class="flex items-center gap-3 text-sm"> 15 + {% if g.user %} 16 + <button onclick="document.getElementById('compose-dialog').showModal()" 17 + class="bg-indigo-600 text-white px-3 py-1 rounded-md hover:bg-indigo-700 text-xs font-medium transition-colors flex items-center gap-1"> 18 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> 19 + share 20 + </button> 21 + <a href="{{ url_for('list_bites', identifier=g.user.did) }}" 22 + class="text-gray-500 hover:text-gray-900 transition-colors"> 23 + <img src="/avatar/{{ g.user.did }}" alt="" class="w-6 h-6 rounded-full bg-gray-100"> 24 + </a> 25 + <a href="{{ url_for('oauth_logout') }}" 26 + class="text-gray-400 hover:text-gray-600 transition-colors">log out</a> 27 + {% else %} 28 + <button onclick="document.getElementById('login-dialog').showModal()" 29 + class="bg-indigo-600 text-white px-3 py-1 rounded-md hover:bg-indigo-700 text-xs font-medium transition-colors">log in</button> 30 + {% endif %} 31 + </nav> 32 + </div> 33 + </header> 34 + 35 + <main class="flex-1"> 36 + <div class="max-w-3xl mx-auto px-5 py-5"> 37 + {% with messages = get_flashed_messages(with_categories=true) %} 38 + {% if messages %} 39 + {% for category, message in messages %} 40 + <div class="mb-4 px-3 py-2 rounded-md text-sm 41 + {% if category == 'error' %}bg-red-50 text-red-800 border border-red-200 42 + {% else %}bg-green-50 text-green-800 border border-green-200{% endif %}"> 43 + {{ message }} 44 + </div> 45 + {% endfor %} 46 + {% endif %} 47 + {% endwith %} 48 + {% block content %}{% endblock %} 49 + </div> 50 + </main> 51 + 52 + <footer class="border-t border-gray-100 py-6"> 53 + <div class="max-w-3xl mx-auto px-5 text-xs text-gray-400 text-center"> 54 + <p>made by <a href="https://aly.codes" class="text-amber-600 hover:text-amber-700 transition-colors">@aly.codes</a></p> 55 + </div> 56 + </footer> 57 + 58 + <!-- Login dialog --> 59 + {% if not g.user %} 60 + <dialog id="login-dialog" 61 + class="bg-white rounded-xl shadow-xl border border-gray-200 p-0 backdrop:bg-gray-900/40 max-w-sm w-full"> 62 + <form method="post" action="{{ url_for('oauth_login') }}" class="p-4"> 63 + <input type="hidden" name="csrf_token" value="{{ session.csrf_token }}"> 64 + <div class="flex items-center justify-between mb-3"> 65 + <p class="text-xs text-gray-400">log in with your handle</p> 66 + <button type="button" onclick="document.getElementById('login-dialog').close()" 67 + class="text-gray-400 hover:text-gray-600 transition-colors ml-3 shrink-0" aria-label="Close"> 68 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 69 + </button> 70 + </div> 71 + <div class="flex gap-2"> 72 + <input type="text" name="username" id="username" placeholder="you.bsky.social" required aria-label="Handle" 73 + class="flex-1 min-w-0 px-3 py-1.5 bg-white border border-gray-300 rounded-md text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"> 74 + <button type="submit" 75 + class="bg-indigo-600 text-white px-3 py-1.5 rounded-md hover:bg-indigo-700 text-xs font-medium transition-colors shrink-0"> 76 + &rarr; 77 + </button> 78 + </div> 79 + </form> 80 + </dialog> 81 + {% endif %} 82 + 83 + <!-- Compose dialog --> 84 + {% if g.user %} 85 + <dialog id="compose-dialog" 86 + class="bg-white rounded-xl shadow-xl border border-gray-200 p-0 backdrop:bg-gray-900/40 max-w-lg w-full"> 87 + <form method="post" action="{{ url_for('create_bite') }}" class="p-0"> 88 + <input type="hidden" name="csrf_token" value="{{ session.csrf_token }}"> 89 + <div class="flex items-center justify-between px-4 py-3 border-b border-gray-100"> 90 + <div class="flex items-center gap-2"> 91 + <img src="/avatar/{{ g.user.did }}" alt="" class="w-5 h-5 rounded-full bg-gray-100"> 92 + <span class="text-xs text-amber-600">{{ session.user_handle }}</span> 93 + </div> 94 + <button type="button" onclick="document.getElementById('compose-dialog').close()" 95 + class="text-gray-400 hover:text-gray-600 transition-colors" aria-label="Close"> 96 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 97 + </button> 98 + </div> 99 + <div class="px-4 py-3 border-b border-gray-100"> 100 + <input type="text" name="title" placeholder="title (optional)" 101 + class="w-full text-sm font-medium text-gray-900 placeholder-gray-400 bg-transparent border-0 p-0 focus:outline-none focus:ring-0"> 102 + </div> 103 + <!-- prettier-ignore --> 104 + <div class="bg-gray-50"> 105 + <textarea name="content" rows="12" required autofocus placeholder="Paste or type something..." 106 + class="w-full px-4 py-3 bg-transparent border-0 text-sm text-gray-800 font-mono placeholder-gray-400 focus:outline-none focus:ring-0 leading-6 resize-none"></textarea> 107 + </div> 108 + <div class="flex justify-end px-4 py-3 border-t border-gray-100"> 109 + <button type="submit" 110 + class="bg-indigo-600 text-white px-4 py-1.5 rounded-md hover:bg-indigo-700 text-sm font-medium transition-colors"> 111 + share 112 + </button> 113 + </div> 114 + </form> 115 + </dialog> 116 + {% endif %} 117 + 118 + <script> 119 + // Profile links inside card links 120 + document.addEventListener('click', function(e) { 121 + var link = e.target.closest('.js-profile-link'); 122 + if (link) { 123 + e.preventDefault(); 124 + e.stopPropagation(); 125 + window.location = link.dataset.href; 126 + } 127 + }); 128 + // Ctrl/Cmd+Enter to submit 129 + document.addEventListener('keydown', function(e) { 130 + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { 131 + var ta = document.activeElement; 132 + if (ta && ta.tagName === 'TEXTAREA') { 133 + ta.closest('form').requestSubmit(); 134 + } 135 + } 136 + }); 137 + // Close dialogs on backdrop click, autofocus first input on open 138 + document.querySelectorAll('dialog').forEach(function(dialog) { 139 + dialog.addEventListener('click', function(e) { 140 + if (e.target === dialog) dialog.close(); 141 + }); 142 + var obs = new MutationObserver(function() { 143 + if (dialog.open) { 144 + var input = dialog.querySelector('input[type="text"], textarea'); 145 + if (input) input.focus(); 146 + } 147 + }); 148 + obs.observe(dialog, { attributes: true, attributeFilter: ['open'] }); 149 + }); 150 + </script> 151 + </body> 152 + </html>
+58
templates/create.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block title %}morsels{% endblock %} 4 + 5 + {% block content %} 6 + <!-- Inline editor --> 7 + <div id="editor" class="mb-6"> 8 + <!-- Collapsed state --> 9 + <div id="editor-collapsed" onclick="expandEditor()" 10 + class="border border-gray-200 rounded-md px-4 py-3 flex items-center gap-3 cursor-pointer hover:border-gray-300 transition-colors"> 11 + <img src="/avatar/{{ g.user.did }}" alt="" class="w-6 h-6 rounded-full bg-gray-100 shrink-0"> 12 + <span class="text-sm text-gray-400">Paste or type something...</span> 13 + </div> 14 + 15 + <!-- Expanded state --> 16 + <form id="editor-expanded" method="post" action="{{ url_for('create_bite') }}" class="hidden"> 17 + <input type="hidden" name="csrf_token" value="{{ session.csrf_token }}"> 18 + <div class="border border-indigo-300 ring-1 ring-indigo-300 rounded-md overflow-hidden"> 19 + <div class="flex items-center gap-2 px-4 py-2.5 border-b border-gray-100 bg-white"> 20 + <img src="/avatar/{{ g.user.did }}" alt="" class="w-5 h-5 rounded-full bg-gray-100 shrink-0"> 21 + <input type="text" name="title" placeholder="title (optional)" 22 + class="text-sm font-medium text-gray-900 placeholder-gray-400 bg-transparent border-0 p-0 focus:outline-none focus:ring-0 flex-1"> 23 + </div> 24 + <!-- prettier-ignore --> 25 + <div class="bg-gray-50"> 26 + <textarea id="editor-textarea" name="content" rows="8" required placeholder="Paste or type something..." 27 + class="w-full px-4 py-3 bg-transparent border-0 text-sm text-gray-800 font-mono placeholder-gray-400 focus:outline-none focus:ring-0 leading-6 resize-none"></textarea> 28 + </div> 29 + <div class="flex items-center justify-between px-4 py-2.5 border-t border-gray-100 bg-white"> 30 + <button type="button" onclick="collapseEditor()" 31 + class="text-xs text-gray-400 hover:text-gray-600 transition-colors">cancel</button> 32 + <button type="submit" 33 + class="bg-indigo-600 text-white px-4 py-1.5 rounded-md hover:bg-indigo-700 text-xs font-medium transition-colors"> 34 + share 35 + </button> 36 + </div> 37 + </div> 38 + </form> 39 + </div> 40 + 41 + <!-- Feed --> 42 + {% if recent %} 43 + <h2 class="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">Recent Bites</h2> 44 + {% include "_recent_bites.html" %} 45 + {% endif %} 46 + 47 + <script> 48 + function expandEditor() { 49 + document.getElementById('editor-collapsed').classList.add('hidden'); 50 + document.getElementById('editor-expanded').classList.remove('hidden'); 51 + document.getElementById('editor-textarea').focus(); 52 + } 53 + function collapseEditor() { 54 + document.getElementById('editor-expanded').classList.add('hidden'); 55 + document.getElementById('editor-collapsed').classList.remove('hidden'); 56 + } 57 + </script> 58 + {% endblock %}
+12
templates/error.html
··· 1 + {% extends "base.html" %} {% block title %}morsels - {{ code }}{% endblock %} {% 2 + block content %} 3 + <div class="text-center py-20"> 4 + <p class="text-6xl font-bold text-gray-200 mb-4">{{ code }}</p> 5 + <p class="text-base text-gray-600 mb-6">{{ message }}</p> 6 + <a 7 + href="/" 8 + class="text-sm text-amber-600 hover:text-amber-700 transition-colors" 9 + >back to home &rarr;</a 10 + > 11 + </div> 12 + {% endblock %}
+16
templates/index.html
··· 1 + {% extends "base.html" %} {% block title %}morsels{% endblock %} {% block 2 + content %} 3 + <!-- Banner --> 4 + <div class="rounded-lg bg-gray-50 border border-gray-200 px-6 py-5 mb-6"> 5 + <p class="text-lg font-semibold text-gray-900">Small bites, big ideas.</p> 6 + <p class="text-sm text-gray-500 mt-1"> 7 + Share a bite with anyone. Your posts, your account, wherever you go. 8 + </p> 9 + </div> 10 + 11 + <!-- Feed --> 12 + {% if recent %} 13 + <h2 class="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2"> 14 + Recent Bites 15 + </h2> 16 + {% include "_recent_bites.html" %} {% endif %} {% endblock %}
+32
templates/list.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block title %}morsels - {% if handle %}{{ handle }}{% else %}{{ did }}{% endif %}{% endblock %} 4 + 5 + {% block content %} 6 + <!-- Profile card --> 7 + <div class="flex items-center gap-3 mb-6 pb-5 border-b border-gray-100"> 8 + {% if profile.avatar_url %} 9 + <img src="{{ profile.avatar_url }}" alt="" class="w-12 h-12 rounded-full"> 10 + {% else %} 11 + <div class="w-12 h-12 rounded-full bg-gray-100"></div> 12 + {% endif %} 13 + <div> 14 + <h1 class="text-base font-semibold text-gray-900"> 15 + {% if profile.display_name %}{{ profile.display_name }} 16 + {% elif handle %}{{ handle }} 17 + {% else %}{{ did }}{% endif %} 18 + </h1> 19 + <div class="flex items-center gap-1.5 mt-0.5"> 20 + {% if handle and profile.display_name %} 21 + <p class="text-xs text-gray-500">{{ handle }}</p> 22 + {% endif %} 23 + {% if profile.pronouns %} 24 + <span class="text-xs text-gray-400">&middot; {{ profile.pronouns }}</span> 25 + {% endif %} 26 + </div> 27 + </div> 28 + </div> 29 + 30 + <!-- Bites --> 31 + {% include "_bite_list.html" %} 32 + {% endblock %}
+95
templates/view.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block title %}morsels - {{ title }}{% endblock %} 4 + 5 + {% block content %} 6 + <div class="border border-gray-200 rounded-md overflow-hidden shadow-sm"> 7 + <div class="relative flex items-center justify-between px-4 py-2.5 border-b border-gray-100"> 8 + <div class="flex items-center gap-1.5"> 9 + <a href="{{ url_for('list_bites', identifier=did) }}"> 10 + {% if profile.avatar_url %} 11 + <img src="{{ profile.avatar_url }}" alt="" class="w-4 h-4 rounded-full"> 12 + {% else %} 13 + <div class="w-4 h-4 rounded-full bg-gray-100"></div> 14 + {% endif %} 15 + </a> 16 + <a href="{{ url_for('list_bites', identifier=did) }}" 17 + class="text-xs text-amber-600 hover:text-amber-700 transition-colors">{{ handle or did }}</a> 18 + </div> 19 + <span class="absolute inset-0 flex items-center justify-center text-sm font-medium text-gray-900 pointer-events-none">{{ title }}</span> 20 + <div class="flex items-baseline gap-2"> 21 + <span class="text-xs text-gray-400">{{ created_at | humandate }}</span> 22 + {% if g.user and g.user.did == did %} 23 + <form method="post" action="{{ url_for('delete_bite', identifier=did, rkey=at_uri.split('/')[-1]) }}" class="inline leading-none"> 24 + <input type="hidden" name="csrf_token" value="{{ session.csrf_token }}"> 25 + <button type="submit" class="text-xs text-gray-400 hover:text-red-500 transition-colors leading-none p-0">delete</button> 26 + </form> 27 + {% endif %} 28 + </div> 29 + </div> 30 + <!-- prettier-ignore --> 31 + <div class="highlight bg-gray-50 overflow-x-auto"> 32 + <table class="w-full text-sm leading-6"> 33 + {% for line in paste_html.split('\n') %} 34 + <tr> 35 + <td class="w-1 pl-3 pr-2 py-0 text-right text-gray-400 select-none border-r border-gray-200 align-top font-mono tabular-nums">{{ loop.index }}</td> 36 + <td class="px-3 py-0 font-mono whitespace-pre text-gray-800">{{ line | safe }}</td> 37 + </tr> 38 + {% endfor %} 39 + </table> 40 + </div> 41 + </div> 42 + 43 + <div class="mb-6"></div> 44 + 45 + <!-- Replies --> 46 + <div class="border-t border-gray-100 pt-5"> 47 + {% if replies %} 48 + <div class="space-y-4 mb-5"> 49 + {% for reply in replies %} 50 + <div class="flex gap-3"> 51 + <div class="shrink-0 mt-0.5"> 52 + <img src="/avatar/{{ reply.did }}" alt="" class="w-5 h-5 rounded-full bg-gray-100"> 53 + </div> 54 + <div class="min-w-0 flex-1"> 55 + <div class="flex items-center gap-2 mb-0.5"> 56 + <a href="{{ url_for('list_bites', identifier=reply.did) }}" 57 + class="text-xs font-medium text-amber-600 hover:text-amber-700 transition-colors">{{ reply.handle or reply.did }}</a> 58 + <span class="text-xs text-gray-400">{{ reply.created_at | humandate }}</span> 59 + </div> 60 + <p class="text-sm text-gray-700 leading-relaxed">{{ reply.text }}</p> 61 + {% if g.user and g.user.did == reply.did and reply.rkey %} 62 + <form method="post" action="{{ url_for('delete_reply', identifier=did, rkey=at_uri.split('/')[-1]) }}" class="mt-1"> 63 + <input type="hidden" name="csrf_token" value="{{ session.csrf_token }}"> 64 + <input type="hidden" name="reply_rkey" value="{{ reply.rkey }}"> 65 + <button type="submit" class="text-xs text-gray-400 hover:text-red-500 transition-colors">delete</button> 66 + </form> 67 + {% endif %} 68 + </div> 69 + </div> 70 + {% endfor %} 71 + </div> 72 + {% endif %} 73 + 74 + {% if g.user %} 75 + <form method="post" action="{{ url_for('create_reply', identifier=did, rkey=paste_id.split('/')[-1]) }}"> 76 + <input type="hidden" name="csrf_token" value="{{ session.csrf_token }}"> 77 + <input type="hidden" name="at_uri" value="{{ at_uri }}"> 78 + <input type="hidden" name="cid" value="{{ cid }}"> 79 + <div class="flex gap-3"> 80 + <div class="shrink-0 mt-1"> 81 + <img src="/avatar/{{ g.user.did }}" alt="" class="w-5 h-5 rounded-full bg-gray-100"> 82 + </div> 83 + <div class="flex-1"> 84 + <textarea name="text" rows="2" required placeholder="Write a reply..." 85 + class="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 resize-none"></textarea> 86 + <div class="flex justify-end mt-1.5"> 87 + <button type="submit" 88 + class="bg-indigo-600 text-white px-3.5 py-1.5 rounded-md hover:bg-indigo-700 text-sm font-medium transition-colors">reply</button> 89 + </div> 90 + </div> 91 + </div> 92 + </form> 93 + {% endif %} 94 + </div> 95 + {% endblock %}
+601
uv.lock
··· 1 + version = 1 2 + revision = 3 3 + requires-python = ">=3.14" 4 + 5 + [[package]] 6 + name = "annotated-types" 7 + version = "0.7.0" 8 + source = { registry = "https://pypi.org/simple" } 9 + sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } 10 + wheels = [ 11 + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, 12 + ] 13 + 14 + [[package]] 15 + name = "anyio" 16 + version = "4.13.0" 17 + source = { registry = "https://pypi.org/simple" } 18 + dependencies = [ 19 + { name = "idna" }, 20 + ] 21 + sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } 22 + wheels = [ 23 + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, 24 + ] 25 + 26 + [[package]] 27 + name = "atproto" 28 + version = "0.0.65" 29 + source = { registry = "https://pypi.org/simple" } 30 + dependencies = [ 31 + { name = "click" }, 32 + { name = "cryptography" }, 33 + { name = "dnspython" }, 34 + { name = "httpx" }, 35 + { name = "libipld" }, 36 + { name = "pydantic" }, 37 + { name = "typing-extensions" }, 38 + { name = "websockets" }, 39 + ] 40 + sdist = { url = "https://files.pythonhosted.org/packages/b2/0f/b6e26f99ef730f1e5779f5833ba794343df78ee1e02041d3b05bd5005066/atproto-0.0.65.tar.gz", hash = "sha256:027c6ed98746a9e6f1bb24bc18db84b80b386037709ff3af9ef927dce3dd4938", size = 210996, upload-time = "2025-12-08T15:53:44.585Z" } 41 + wheels = [ 42 + { url = "https://files.pythonhosted.org/packages/e3/d9/360149e7bd9bac580496ce9fddc0ef320b3813aadd72be6abc011600862d/atproto-0.0.65-py3-none-any.whl", hash = "sha256:ea53dea57454c9e56318b5d25ceb35854d60ba238b38b0e5ca79aa1a2df85846", size = 446650, upload-time = "2025-12-08T15:53:43.029Z" }, 43 + ] 44 + 45 + [[package]] 46 + name = "authlib" 47 + version = "1.6.9" 48 + source = { registry = "https://pypi.org/simple" } 49 + dependencies = [ 50 + { name = "cryptography" }, 51 + ] 52 + sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } 53 + wheels = [ 54 + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, 55 + ] 56 + 57 + [[package]] 58 + name = "blinker" 59 + version = "1.9.0" 60 + source = { registry = "https://pypi.org/simple" } 61 + sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } 62 + wheels = [ 63 + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, 64 + ] 65 + 66 + [[package]] 67 + name = "certifi" 68 + version = "2026.2.25" 69 + source = { registry = "https://pypi.org/simple" } 70 + sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } 71 + wheels = [ 72 + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, 73 + ] 74 + 75 + [[package]] 76 + name = "cffi" 77 + version = "2.0.0" 78 + source = { registry = "https://pypi.org/simple" } 79 + dependencies = [ 80 + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, 81 + ] 82 + sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } 83 + wheels = [ 84 + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, 85 + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, 86 + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, 87 + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, 88 + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, 89 + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, 90 + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, 91 + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, 92 + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, 93 + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, 94 + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, 95 + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, 96 + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, 97 + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, 98 + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, 99 + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, 100 + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, 101 + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, 102 + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, 103 + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, 104 + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, 105 + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, 106 + ] 107 + 108 + [[package]] 109 + name = "charset-normalizer" 110 + version = "3.4.6" 111 + source = { registry = "https://pypi.org/simple" } 112 + sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } 113 + wheels = [ 114 + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, 115 + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, 116 + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, 117 + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, 118 + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, 119 + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, 120 + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, 121 + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, 122 + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, 123 + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, 124 + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, 125 + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, 126 + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, 127 + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, 128 + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, 129 + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, 130 + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, 131 + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, 132 + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, 133 + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, 134 + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, 135 + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, 136 + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, 137 + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, 138 + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, 139 + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, 140 + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, 141 + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, 142 + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, 143 + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, 144 + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, 145 + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, 146 + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, 147 + ] 148 + 149 + [[package]] 150 + name = "click" 151 + version = "8.3.1" 152 + source = { registry = "https://pypi.org/simple" } 153 + dependencies = [ 154 + { name = "colorama", marker = "sys_platform == 'win32'" }, 155 + ] 156 + sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } 157 + wheels = [ 158 + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, 159 + ] 160 + 161 + [[package]] 162 + name = "colorama" 163 + version = "0.4.6" 164 + source = { registry = "https://pypi.org/simple" } 165 + sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 166 + wheels = [ 167 + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 168 + ] 169 + 170 + [[package]] 171 + name = "cryptography" 172 + version = "46.0.6" 173 + source = { registry = "https://pypi.org/simple" } 174 + dependencies = [ 175 + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, 176 + ] 177 + sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } 178 + wheels = [ 179 + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, 180 + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, 181 + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, 182 + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, 183 + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, 184 + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, 185 + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, 186 + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, 187 + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, 188 + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, 189 + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, 190 + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, 191 + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, 192 + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, 193 + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, 194 + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, 195 + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, 196 + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, 197 + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, 198 + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, 199 + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, 200 + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, 201 + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, 202 + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, 203 + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, 204 + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, 205 + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, 206 + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, 207 + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, 208 + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, 209 + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, 210 + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, 211 + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, 212 + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, 213 + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, 214 + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, 215 + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, 216 + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, 217 + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, 218 + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, 219 + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, 220 + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, 221 + ] 222 + 223 + [[package]] 224 + name = "dnspython" 225 + version = "2.8.0" 226 + source = { registry = "https://pypi.org/simple" } 227 + sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } 228 + wheels = [ 229 + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, 230 + ] 231 + 232 + [[package]] 233 + name = "flask" 234 + version = "3.1.3" 235 + source = { registry = "https://pypi.org/simple" } 236 + dependencies = [ 237 + { name = "blinker" }, 238 + { name = "click" }, 239 + { name = "itsdangerous" }, 240 + { name = "jinja2" }, 241 + { name = "markupsafe" }, 242 + { name = "werkzeug" }, 243 + ] 244 + sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } 245 + wheels = [ 246 + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, 247 + ] 248 + 249 + [[package]] 250 + name = "gunicorn" 251 + version = "25.3.0" 252 + source = { registry = "https://pypi.org/simple" } 253 + dependencies = [ 254 + { name = "packaging" }, 255 + ] 256 + sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } 257 + wheels = [ 258 + { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, 259 + ] 260 + 261 + [[package]] 262 + name = "h11" 263 + version = "0.16.0" 264 + source = { registry = "https://pypi.org/simple" } 265 + sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } 266 + wheels = [ 267 + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, 268 + ] 269 + 270 + [[package]] 271 + name = "httpcore" 272 + version = "1.0.9" 273 + source = { registry = "https://pypi.org/simple" } 274 + dependencies = [ 275 + { name = "certifi" }, 276 + { name = "h11" }, 277 + ] 278 + sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } 279 + wheels = [ 280 + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, 281 + ] 282 + 283 + [[package]] 284 + name = "httpx" 285 + version = "0.28.1" 286 + source = { registry = "https://pypi.org/simple" } 287 + dependencies = [ 288 + { name = "anyio" }, 289 + { name = "certifi" }, 290 + { name = "httpcore" }, 291 + { name = "idna" }, 292 + ] 293 + sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } 294 + wheels = [ 295 + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, 296 + ] 297 + 298 + [[package]] 299 + name = "idna" 300 + version = "3.11" 301 + source = { registry = "https://pypi.org/simple" } 302 + sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } 303 + wheels = [ 304 + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, 305 + ] 306 + 307 + [[package]] 308 + name = "itsdangerous" 309 + version = "2.2.0" 310 + source = { registry = "https://pypi.org/simple" } 311 + sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } 312 + wheels = [ 313 + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, 314 + ] 315 + 316 + [[package]] 317 + name = "jinja2" 318 + version = "3.1.6" 319 + source = { registry = "https://pypi.org/simple" } 320 + dependencies = [ 321 + { name = "markupsafe" }, 322 + ] 323 + sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } 324 + wheels = [ 325 + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, 326 + ] 327 + 328 + [[package]] 329 + name = "libipld" 330 + version = "3.3.2" 331 + source = { registry = "https://pypi.org/simple" } 332 + sdist = { url = "https://files.pythonhosted.org/packages/83/2b/4e84e033268d2717c692e5034e016b1d82501736cd297586fd1c7378ccd5/libipld-3.3.2.tar.gz", hash = "sha256:7e85ccd9136110e63943d95232b193c893e369c406273d235160e5cc4b39c9ce", size = 4401259, upload-time = "2025-12-05T13:00:20.34Z" } 333 + wheels = [ 334 + { url = "https://files.pythonhosted.org/packages/de/d6/9ab52adf13ee501b50624ef1265657aa30b3267998dfadcb44d77bbeef42/libipld-3.3.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5947e99b40e923170094a3313c9f3629c6ed475465ba95eadce6cdcf08f1f65a", size = 268909, upload-time = "2025-12-05T12:59:02.485Z" }, 335 + { url = "https://files.pythonhosted.org/packages/c2/12/d6f04fb3d6911a276940c89b5ad3e6168d79fda9ae79a812d4da91c433d6/libipld-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f46179c722baf74c627c01c0bf85be7fcbde66bbf7c5f8c1bbb57bd3a17b861b", size = 261052, upload-time = "2025-12-05T12:59:03.829Z" }, 336 + { url = "https://files.pythonhosted.org/packages/d8/23/6cade33d39f00eb71fde1c8fe6f73c5db5274ef8abeac3d2e6d989e65718/libipld-3.3.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e3e9be4bdeb90dbc537a53f8d06e8b2c703f4b7868f9316958e1bbde526a143", size = 280280, upload-time = "2025-12-05T12:59:05.13Z" }, 337 + { url = "https://files.pythonhosted.org/packages/2c/42/50445b6c1c418a3514feb7d267d308e9fb9fe473fbbfaa205bc288ffe5ed/libipld-3.3.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b155c02626b194439f4b519a53985aedc8637ae56cf640ea6acf6172a37465de", size = 290306, upload-time = "2025-12-05T12:59:06.372Z" }, 338 + { url = "https://files.pythonhosted.org/packages/bf/b1/7c197e21f1635ba31b2f4e893d3368598a48d990cebc4308ba496bad1409/libipld-3.3.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a1d84c630961cff188deaa2129c86d69f5779c8d02046fbe0c629ef162bc3df", size = 315801, upload-time = "2025-12-05T12:59:07.918Z" }, 339 + { url = "https://files.pythonhosted.org/packages/83/df/51a549e3017cc496a80852063124793007cb9b4cf2cae2e8a99f5c3dd814/libipld-3.3.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5393886a7e387751904681ecfa7e5912471b46043f044baa041a2b4772e4f839", size = 330420, upload-time = "2025-12-05T12:59:09.185Z" }, 340 + { url = "https://files.pythonhosted.org/packages/2e/f8/84107ad6431311283dadf697fd238ea271e0af1068a0d13e574be5027f32/libipld-3.3.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44ca1ba44cb801686557e9544d248e013a2d5d1ab9fed796f090bb0d51d8f4ef", size = 283791, upload-time = "2025-12-05T12:59:10.481Z" }, 341 + { url = "https://files.pythonhosted.org/packages/35/c5/e3c5116b66383f7e54b9d1feb6d6e254a383311a4cce2940942f07d45893/libipld-3.3.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd0877ef4a1bd6e42ba52659769b5b766583c67b3cfb4e7143f9d10b81fb7a74", size = 309401, upload-time = "2025-12-05T12:59:11.711Z" }, 342 + { url = "https://files.pythonhosted.org/packages/bd/b5/b9345d47569806e6f0041d339c9a1ec0be765fd8a3588308a7a40c383dd9/libipld-3.3.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:91b02da059a6ae7f783efa826f640ab1ca5eb5dd370bfd3f41071693a363c4fb", size = 463929, upload-time = "2025-12-05T12:59:13.344Z" }, 343 + { url = "https://files.pythonhosted.org/packages/92/4b/ae985a308191771e5a9e8e3108a3a4ed7090147e21a7cda0c0e345adc22a/libipld-3.3.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:95a2c4f507c88c01a797ec97ce10603bea684c03208227703e007485dc631971", size = 460308, upload-time = "2025-12-05T12:59:14.702Z" }, 344 + { url = "https://files.pythonhosted.org/packages/5c/d6/98aafc9721dd239e578e2826cbb1e9ef438d76c0ec125bce64346e439041/libipld-3.3.2-cp314-cp314-win32.whl", hash = "sha256:5a50cbf5b3b73164fbb88169573ed3e824024c12fbc5f9efd87fb5c8f236ccc1", size = 159315, upload-time = "2025-12-05T12:59:16.004Z" }, 345 + { url = "https://files.pythonhosted.org/packages/e2/9c/6b7b91a417162743d9ea109e142fe485b2f6dafadb276c6e5a393f772715/libipld-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:c1f3ed8f70b215a294b5c6830e91af48acde96b3c8a6cae13304291f8240b939", size = 159168, upload-time = "2025-12-05T12:59:17.308Z" }, 346 + { url = "https://files.pythonhosted.org/packages/22/19/bb42dc53bb8855c1f40b4a431ed3cb2df257bd5a6af61842626712c83073/libipld-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:08261503b7307c6d9acbd3b2a221da9294b457204dcefce446f627893abb077e", size = 149324, upload-time = "2025-12-05T12:59:18.815Z" }, 347 + ] 348 + 349 + [[package]] 350 + name = "markupsafe" 351 + version = "3.0.3" 352 + source = { registry = "https://pypi.org/simple" } 353 + sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } 354 + wheels = [ 355 + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, 356 + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, 357 + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, 358 + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, 359 + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, 360 + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, 361 + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, 362 + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, 363 + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, 364 + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, 365 + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, 366 + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, 367 + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, 368 + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, 369 + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, 370 + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, 371 + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, 372 + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, 373 + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, 374 + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, 375 + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, 376 + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, 377 + ] 378 + 379 + [[package]] 380 + name = "morsels" 381 + version = "0.1.0" 382 + source = { virtual = "." } 383 + dependencies = [ 384 + { name = "atproto" }, 385 + { name = "authlib" }, 386 + { name = "flask" }, 387 + { name = "gunicorn" }, 388 + { name = "pygments" }, 389 + { name = "regex" }, 390 + { name = "requests-hardened" }, 391 + ] 392 + 393 + [package.metadata] 394 + requires-dist = [ 395 + { name = "atproto", specifier = ">=0.0.65" }, 396 + { name = "authlib", specifier = ">=1.6.9" }, 397 + { name = "flask", specifier = ">=3.1.3" }, 398 + { name = "gunicorn", specifier = ">=25.3.0" }, 399 + { name = "pygments", specifier = ">=2.20.0" }, 400 + { name = "regex", specifier = ">=2026.3.32" }, 401 + { name = "requests-hardened", specifier = ">=1.2.0" }, 402 + ] 403 + 404 + [[package]] 405 + name = "packaging" 406 + version = "26.0" 407 + source = { registry = "https://pypi.org/simple" } 408 + sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } 409 + wheels = [ 410 + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, 411 + ] 412 + 413 + [[package]] 414 + name = "pycparser" 415 + version = "3.0" 416 + source = { registry = "https://pypi.org/simple" } 417 + sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } 418 + wheels = [ 419 + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, 420 + ] 421 + 422 + [[package]] 423 + name = "pydantic" 424 + version = "2.12.5" 425 + source = { registry = "https://pypi.org/simple" } 426 + dependencies = [ 427 + { name = "annotated-types" }, 428 + { name = "pydantic-core" }, 429 + { name = "typing-extensions" }, 430 + { name = "typing-inspection" }, 431 + ] 432 + sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } 433 + wheels = [ 434 + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, 435 + ] 436 + 437 + [[package]] 438 + name = "pydantic-core" 439 + version = "2.41.5" 440 + source = { registry = "https://pypi.org/simple" } 441 + dependencies = [ 442 + { name = "typing-extensions" }, 443 + ] 444 + sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } 445 + wheels = [ 446 + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, 447 + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, 448 + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, 449 + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, 450 + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, 451 + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, 452 + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, 453 + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, 454 + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, 455 + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, 456 + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, 457 + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, 458 + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, 459 + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, 460 + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, 461 + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, 462 + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, 463 + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, 464 + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, 465 + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, 466 + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, 467 + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, 468 + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, 469 + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, 470 + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, 471 + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, 472 + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, 473 + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, 474 + ] 475 + 476 + [[package]] 477 + name = "pygments" 478 + version = "2.20.0" 479 + source = { registry = "https://pypi.org/simple" } 480 + sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } 481 + wheels = [ 482 + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, 483 + ] 484 + 485 + [[package]] 486 + name = "regex" 487 + version = "2026.3.32" 488 + source = { registry = "https://pypi.org/simple" } 489 + sdist = { url = "https://files.pythonhosted.org/packages/81/93/5ab3e899c47fa7994e524447135a71cd121685a35c8fe35029005f8b236f/regex-2026.3.32.tar.gz", hash = "sha256:f1574566457161678297a116fa5d1556c5a4159d64c5ff7c760e7c564bf66f16", size = 415605, upload-time = "2026-03-28T21:49:22.012Z" } 490 + wheels = [ 491 + { url = "https://files.pythonhosted.org/packages/32/68/ff024bf6131b7446a791a636dbbb7fa732d586f33b276d84b3460ea49393/regex-2026.3.32-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a416ee898ecbc5d8b283223b4cf4d560f93244f6f7615c1bd67359744b00c166", size = 490430, upload-time = "2026-03-28T21:48:05.654Z" }, 492 + { url = "https://files.pythonhosted.org/packages/61/72/039d9164817ee298f2a2d0246001afe662241dcbec0eedd1fe03e2a2555e/regex-2026.3.32-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d76d62909bfb14521c3f7cfd5b94c0c75ec94b0a11f647d2f604998962ec7b6c", size = 291948, upload-time = "2026-03-28T21:48:07.666Z" }, 493 + { url = "https://files.pythonhosted.org/packages/06/9d/77f684d90ffe3e99b828d3cabb87a0f1601d2b9decd1333ff345809b1d02/regex-2026.3.32-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:631f7d95c83f42bccfe18946a38ad27ff6b6717fb4807e60cf24860b5eb277fc", size = 289786, upload-time = "2026-03-28T21:48:09.562Z" }, 494 + { url = "https://files.pythonhosted.org/packages/83/70/bd76069a0304e924682b2efd8683a01617a7e1da9b651af73039d8da76a4/regex-2026.3.32-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12917c6c6813ffcdfb11680a04e4d63c5532b88cf089f844721c5f41f41a63ad", size = 796672, upload-time = "2026-03-28T21:48:11.568Z" }, 495 + { url = "https://files.pythonhosted.org/packages/80/31/c2d7d9a5671e111a2c16d57e0cb03e1ce35b28a115901590528aa928bb5b/regex-2026.3.32-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e221b615f83b15887636fcb90ed21f1a19541366f8b7ba14ba1ad8304f4ded4", size = 866556, upload-time = "2026-03-28T21:48:14.081Z" }, 496 + { url = "https://files.pythonhosted.org/packages/d7/b9/9921a31931d0bc3416ac30205471e0e2ed60dcbd16fc922bbd69b427322b/regex-2026.3.32-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f9ae4755fa90f1dc2d0d393d572ebc134c0fe30fcfc0ab7e67c1db15f192041", size = 912787, upload-time = "2026-03-28T21:48:16.548Z" }, 497 + { url = "https://files.pythonhosted.org/packages/41/ab/2c1bc8ab99f63cdabdbc7823af8f4cfcd6ddbb2babf01861826c3f1ad44d/regex-2026.3.32-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a094e9dcafedfb9d333db5cf880304946683f43a6582bb86688f123335122929", size = 800879, upload-time = "2026-03-28T21:48:18.971Z" }, 498 + { url = "https://files.pythonhosted.org/packages/49/e5/0be716eb2c0b2ae3a439e44432534e82b2f81848af64cb21c0473ad8ae46/regex-2026.3.32-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c1cecea3e477af105f32ef2119b8d895f297492e41d317e60d474bc4bffd62ff", size = 776332, upload-time = "2026-03-28T21:48:21.163Z" }, 499 + { url = "https://files.pythonhosted.org/packages/26/80/114a61bd25dec7d1070930eaef82aadf9b05961a37629e7cca7bc3fc2257/regex-2026.3.32-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f26262900edd16272b6360014495e8d68379c6c6e95983f9b7b322dc928a1194", size = 786384, upload-time = "2026-03-28T21:48:23.277Z" }, 500 + { url = "https://files.pythonhosted.org/packages/0c/78/be0a6531f8db426e8e60d6356aeef8e9cc3f541655a648c4968b63c87a88/regex-2026.3.32-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1cb22fa9ee6a0acb22fc9aecce5f9995fe4d2426ed849357d499d62608fbd7f9", size = 861381, upload-time = "2026-03-28T21:48:25.371Z" }, 501 + { url = "https://files.pythonhosted.org/packages/45/b1/e5076fbe45b8fb39672584b1b606d512f5bd3a43155be68a95f6b88c1fc5/regex-2026.3.32-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9b9118a78e031a2e4709cd2fcc3028432e89b718db70073a8da574c249b5b249", size = 765434, upload-time = "2026-03-28T21:48:27.494Z" }, 502 + { url = "https://files.pythonhosted.org/packages/a3/da/fd65d68b897f8b52b1390d20d776fa753582484724a9cb4f4c26de657ae5/regex-2026.3.32-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b193ed199848aa96618cd5959c1582a0bf23cd698b0b900cb0ffe81b02c8659c", size = 851501, upload-time = "2026-03-28T21:48:29.884Z" }, 503 + { url = "https://files.pythonhosted.org/packages/e8/d6/1e9c991c32022a9312e9124cc974961b3a2501338de2cd1cce75a3612d7a/regex-2026.3.32-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:10fb2aaae1aaadf7d43c9f3c2450404253697bf8b9ce360bd5418d1d16292298", size = 788076, upload-time = "2026-03-28T21:48:32.025Z" }, 504 + { url = "https://files.pythonhosted.org/packages/f0/5b/b23c72f6d607cbb24ef42acf0c7c2ef4eee1377a9f7ba43b312f889edfbb/regex-2026.3.32-cp314-cp314-win32.whl", hash = "sha256:110ba4920721374d16c4c8ea7ce27b09546d43e16aea1d7f43681b5b8f80ba61", size = 272255, upload-time = "2026-03-28T21:48:34.355Z" }, 505 + { url = "https://files.pythonhosted.org/packages/2a/ec/32bbcc42366097a8cea2c481e02964be6c6fa5ccfb0fa9581686af0bec5f/regex-2026.3.32-cp314-cp314-win_amd64.whl", hash = "sha256:245667ad430745bae6a1e41081872d25819d86fbd9e0eec485ba00d9f78ad43d", size = 281160, upload-time = "2026-03-28T21:48:36.588Z" }, 506 + { url = "https://files.pythonhosted.org/packages/6c/e4/89038a028cb68e719fa03ab1ad603649fc199bcda12270d2ac7b471b8f5d/regex-2026.3.32-cp314-cp314-win_arm64.whl", hash = "sha256:1ca02ff0ef33e9d8276a1fcd6d90ff6ea055a32c9149c0050b5b67e26c6d2c51", size = 273688, upload-time = "2026-03-28T21:48:38.976Z" }, 507 + { url = "https://files.pythonhosted.org/packages/30/6e/87caccd608837a1fa4f8c7edc48e206103452b9bbc94fc724fa39340e807/regex-2026.3.32-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:51fb7e26f91f9091fd8ec6a946f99b15d3bc3667cb5ddc73dd6cb2222dd4a1cc", size = 494506, upload-time = "2026-03-28T21:48:41.327Z" }, 508 + { url = "https://files.pythonhosted.org/packages/16/53/a922e6b24694d70bdd68fc3fd076950e15b1b418cff9d2cc362b3968d86f/regex-2026.3.32-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:51a93452034d671b0e21b883d48ea66c5d6a05620ee16a9d3f229e828568f3f0", size = 293986, upload-time = "2026-03-28T21:48:43.481Z" }, 509 + { url = "https://files.pythonhosted.org/packages/60/e4/0cb32203c1aebad0577fcd5b9af1fe764869e617d5234bc6a0ad284299ea/regex-2026.3.32-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:03c2ebd15ff51e7b13bb3dc28dd5ac18cd39e59ebb40430b14ae1a19e833cff1", size = 292677, upload-time = "2026-03-28T21:48:45.772Z" }, 510 + { url = "https://files.pythonhosted.org/packages/f0/f8/5006b70291469d4174dd66ad162802e2f68419c0f2a7952d0c76c1288cfa/regex-2026.3.32-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bf2f3c2c5bd8360d335c7dcd4a9006cf1dabae063ee2558ee1b07bbc8a20d88", size = 810661, upload-time = "2026-03-28T21:48:48.147Z" }, 511 + { url = "https://files.pythonhosted.org/packages/b2/9b/438763a20d22cd1f65f95c8f030dd25df2d80a941068a891d21a5f240456/regex-2026.3.32-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a4a3189a99ecdd1c13f42513ab3fc7fa8311b38ba7596dd98537acb8cd9acc3", size = 872156, upload-time = "2026-03-28T21:48:50.739Z" }, 512 + { url = "https://files.pythonhosted.org/packages/6c/5b/1341287887ac982ed9f5f60125e440513ffe354aa7e3681940495af7c12a/regex-2026.3.32-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c0bbfbd38506e1ea96a85da6782577f06239cb9fcf9696f1ea537c980c0680b", size = 916749, upload-time = "2026-03-28T21:48:53.57Z" }, 513 + { url = "https://files.pythonhosted.org/packages/42/e2/1d2b48b8e94debfffc6fefb84d2a86a178cc208652a1d6493d5f29821c70/regex-2026.3.32-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8aaf8ee8f34b677f90742ca089b9c83d64bdc410528767273c816a863ed57327", size = 814788, upload-time = "2026-03-28T21:48:55.905Z" }, 514 + { url = "https://files.pythonhosted.org/packages/a6/d9/7dacb34c43adaeb954518d851f3e5d3ce495ac00a9d6010e3b4b59917c4a/regex-2026.3.32-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ea568832eca219c2be1721afa073c1c9eb8f98a9733fdedd0a9747639fc22a5", size = 786594, upload-time = "2026-03-28T21:48:58.404Z" }, 515 + { url = "https://files.pythonhosted.org/packages/ea/72/28295068c92dbd6d3ce4fd22554345cf504e957cc57dadeda4a64fa86a57/regex-2026.3.32-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e4c8fa46aad1a11ae2f8fcd1c90b9d55e18925829ac0d98c5bb107f93351745", size = 800167, upload-time = "2026-03-28T21:49:01.226Z" }, 516 + { url = "https://files.pythonhosted.org/packages/ca/17/b10745adeca5b8d52da050e7c746137f5d01dabc6dbbe6e8d9d821dc65c1/regex-2026.3.32-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cec365d44835b043d7b3266487797639d07d621bec9dc0ea224b00775797cc1", size = 865906, upload-time = "2026-03-28T21:49:03.484Z" }, 517 + { url = "https://files.pythonhosted.org/packages/45/9d/1acbcce765044ac0c87f453f4876e0897f7a61c10315262f960184310798/regex-2026.3.32-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:09e26cad1544d856da85881ad292797289e4406338afe98163f3db9f7fac816c", size = 772642, upload-time = "2026-03-28T21:49:06.811Z" }, 518 + { url = "https://files.pythonhosted.org/packages/24/41/1ef8b4811355ad7b9d7579d3aeca00f18b7bc043ace26c8c609b9287346d/regex-2026.3.32-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:6062c4ef581a3e9e503dccf4e1b7f2d33fdc1c13ad510b287741ac73bc4c6b27", size = 856927, upload-time = "2026-03-28T21:49:09.373Z" }, 519 + { url = "https://files.pythonhosted.org/packages/97/b1/0dc1d361be80ec1b8b707ada041090181133a7a29d438e432260a4b26f9a/regex-2026.3.32-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88ebc0783907468f17fca3d7821b30f9c21865a721144eb498cb0ff99a67bcac", size = 801910, upload-time = "2026-03-28T21:49:11.818Z" }, 520 + { url = "https://files.pythonhosted.org/packages/b5/db/1a23f767fa250844772a9464306d34e0fafe2c317303b88a1415096b6324/regex-2026.3.32-cp314-cp314t-win32.whl", hash = "sha256:e480d3dac06c89bc2e0fd87524cc38c546ac8b4a38177650745e64acbbcfdeba", size = 275714, upload-time = "2026-03-28T21:49:14.528Z" }, 521 + { url = "https://files.pythonhosted.org/packages/c2/2b/616d31b125ca76079d74d6b1d84ec0860ffdb41c379151135d06e35a8633/regex-2026.3.32-cp314-cp314t-win_amd64.whl", hash = "sha256:67015a8162d413af9e3309d9a24e385816666fbf09e48e3ec43342c8536f7df6", size = 285722, upload-time = "2026-03-28T21:49:16.642Z" }, 522 + { url = "https://files.pythonhosted.org/packages/7e/91/043d9a00d6123c5fa22a3dc96b10445ce434a8110e1d5e53efb01f243c8b/regex-2026.3.32-cp314-cp314t-win_arm64.whl", hash = "sha256:1a6ac1ed758902e664e0d95c1ee5991aa6fb355423f378ed184c6ec47a1ec0e9", size = 275700, upload-time = "2026-03-28T21:49:19.348Z" }, 523 + ] 524 + 525 + [[package]] 526 + name = "requests" 527 + version = "2.33.1" 528 + source = { registry = "https://pypi.org/simple" } 529 + dependencies = [ 530 + { name = "certifi" }, 531 + { name = "charset-normalizer" }, 532 + { name = "idna" }, 533 + { name = "urllib3" }, 534 + ] 535 + sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } 536 + wheels = [ 537 + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, 538 + ] 539 + 540 + [[package]] 541 + name = "requests-hardened" 542 + version = "1.2.0" 543 + source = { registry = "https://pypi.org/simple" } 544 + dependencies = [ 545 + { name = "requests" }, 546 + ] 547 + sdist = { url = "https://files.pythonhosted.org/packages/0d/ab/3206848b4657be7902bb10af5686f71da450d9135340ecd6ee80da718557/requests_hardened-1.2.0.tar.gz", hash = "sha256:24ff13c798a22afc3465c24ff955b003c81f605e2ec30cbdbd40f28389cfca72", size = 7254, upload-time = "2025-09-26T12:20:20.518Z" } 548 + wheels = [ 549 + { url = "https://files.pythonhosted.org/packages/8d/0e/b521e2034f0984b3a446009223e8ec67bfae5e3d4a11b0066951d2df6515/requests_hardened-1.2.0-py3-none-any.whl", hash = "sha256:7d70b38bbfdea3f1d27d9149a5967f8c350b3496d232b1d4b031b7d0f2590ba9", size = 9197, upload-time = "2025-09-26T12:20:19.126Z" }, 550 + ] 551 + 552 + [[package]] 553 + name = "typing-extensions" 554 + version = "4.15.0" 555 + source = { registry = "https://pypi.org/simple" } 556 + sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } 557 + wheels = [ 558 + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, 559 + ] 560 + 561 + [[package]] 562 + name = "typing-inspection" 563 + version = "0.4.2" 564 + source = { registry = "https://pypi.org/simple" } 565 + dependencies = [ 566 + { name = "typing-extensions" }, 567 + ] 568 + sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } 569 + wheels = [ 570 + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, 571 + ] 572 + 573 + [[package]] 574 + name = "urllib3" 575 + version = "2.6.3" 576 + source = { registry = "https://pypi.org/simple" } 577 + sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } 578 + wheels = [ 579 + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, 580 + ] 581 + 582 + [[package]] 583 + name = "websockets" 584 + version = "15.0.1" 585 + source = { registry = "https://pypi.org/simple" } 586 + sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } 587 + wheels = [ 588 + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, 589 + ] 590 + 591 + [[package]] 592 + name = "werkzeug" 593 + version = "3.1.7" 594 + source = { registry = "https://pypi.org/simple" } 595 + dependencies = [ 596 + { name = "markupsafe" }, 597 + ] 598 + sdist = { url = "https://files.pythonhosted.org/packages/b5/43/76ded108b296a49f52de6bac5192ca1c4be84e886f9b5c9ba8427d9694fd/werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351", size = 875700, upload-time = "2026-03-24T01:08:07.687Z" } 599 + wheels = [ 600 + { url = "https://files.pythonhosted.org/packages/7f/b2/0bba9bbb4596d2d2f285a16c2ab04118f6b957d8441566e1abb892e6a6b2/werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f", size = 226295, upload-time = "2026-03-24T01:08:06.133Z" }, 601 + ]