Compare commits

..
4 Commits
Author SHA1 Message Date
ducoterra a2c4f81147 fix heartbeat issue
Build and Push Containers / test (push) Successful in 9s
Build and Push Containers / build-and-push (push) Successful in 16s
2026-08-02 11:35:13 -04:00
ducoterra 121ecece3c fix queuing system to break less
Build and Push Containers / test (push) Successful in 15s
Build and Push Containers / build-and-push (push) Successful in 29s
2026-08-01 23:32:53 -04:00
ducoterra d487fad8fa increase workers and fix async issues 2026-08-01 22:24:29 -04:00
ducoterra 73b2b7972c up workers to 16
Build and Push Containers / test (push) Successful in 10s
Build and Push Containers / build-and-push (push) Successful in 13s
2026-08-01 21:58:27 -04:00
6 changed files with 681 additions and 149 deletions
+23 -10
View File
@@ -1,34 +1,47 @@
# Web On The Fly # Web On The Fly
Flask app that streams AI-generated fake websites via SSE. Flask app that queues requests, then streams AI-generated fake websites via SSE through a Valkey-backed worker.
This is a Podman project. Use `podman` instead of `docker` for all container commands. This is a Podman project. Use `podman` instead of `docker` for all container commands.
## Commands ## Commands
- `uv run app.py` — start dev server on port 5000 - `uv run app.py` — start dev server (includes worker thread) on port 5000
- `uv run pyright app.py` — typecheck (strict mode in pyproject.toml) - `uv run pyright app.py` — typecheck (strict mode)
- `uv run ruff check app.py` — lint - `uv run ruff check app.py` — lint
- `uv run ruff check app.py --fix` — auto-fix lint issues - `uv run ruff check app.py --fix` — auto-fix lint issues
- `podman compose up -d` — start Valkey (required dependency)
Run lint/typecheck after any code changes. Run lint/typecheck after any code changes.
## Architecture ## Architecture
- Single file: `app.py` - Single file: `app.py`
- `/` → returns a loading page with spinner + live character counter - Valkey (Redis-compatible) is required for request queuing, chunk buffering, and worker coordination
- `/stream` → SSE endpoint; streams LLM output base64-encoded per chunk - Request flow:
- Client JS buffers chunks in-memory (not localStorage), then replaces the document on `[DONE]` - `/` → loading page with SSE client
- `/stream` → enqueues request in Valkey, then polls for worker-generated chunks
- Background worker (thread in dev, gunicorn_post_fork in prod) pops from queue, calls LLM, buffers base64-encoded chunks
- Client decodes chunks in-memory, replaces document on `[RENDER]`
- After LLM generation: validates HTML with lxml + LLM JS review, auto-fixes up to 2 times
- THEMES list (50+ items): tuples of `(site_type, description)` — worker picks randomly
## Env vars (loaded from `.env` via python-dotenv) ## Env vars (loaded from `.env` via python-dotenv)
- `OPENAI_API_KEY` — required - `OPENAI_API_KEY` — required
- `OPENAI_API_URL` — OpenAI-compatible endpoint (default: aipi.reeseapps.com) - `OPENAI_API_URL` — OpenAI-compatible endpoint (code default: `https://api.openai.com/v1/chat/completions`; `.env.example` uses `https://aipi.reeseapps.com/v1/chat/completions`)
- `MODEL` — model name (default: turbo) - `MODEL` — model name (code default: `gpt-4o-mini`; `.env.example` uses `turbo`)
- `VALKEY_URL` — Valkey/Redis URL (default: `redis://localhost:6379/0`)
## Production
- Runs via gunicorn: 16 workers, 300s timeout
- `gunicorn_post_fork` hook ensures exactly one worker thread across processes (Valkey-based lock)
- Containerfile uses `uv sync --frozen --no-dev`, runs with `uv run --no-sync`
## Key details ## Key details
- LLM is called with `stream=True`; chunks are base64-encoded to avoid SSE protocol breaks - LLM streaming uses `stream=True`; chunks are base64-encoded to avoid SSE protocol breaks
- HTTP timeout is 300s (long generations) - HTTP timeout is 300s (long generations)
- Themes defined in `THEMES` list: tuples of `(site_type, description)` (fake_store, fake_wiki, fake_social, etc.)
- `pyright` is configured in strict mode; use `# noqa: BLE001` for intentional broad exception catches - `pyright` is configured in strict mode; use `# noqa: BLE001` for intentional broad exception catches
- `lxml.html` is imported dynamically via `importlib.import_module`
+1
View File
@@ -35,6 +35,7 @@ Loaded from `.env` via `python-dotenv`:
| `OPENAI_API_KEY` | Yes | — | API key for the LLM provider | | `OPENAI_API_KEY` | Yes | — | API key for the LLM provider |
| `OPENAI_API_URL` | No | `https://api.openai.com/v1/chat/completions` | OpenAI-compatible endpoint | | `OPENAI_API_URL` | No | `https://api.openai.com/v1/chat/completions` | OpenAI-compatible endpoint |
| `MODEL` | No | `gpt-4o-mini` | Model name to use | | `MODEL` | No | `gpt-4o-mini` | Model name to use |
| `VALKEY_URL` | No | `redis://localhost:6379/0` | Valkey/Redis URL |
## Site types ## Site types
+476 -137
View File
@@ -1,10 +1,11 @@
import base64 import base64
import importlib
import json import json
import logging import logging
import os import os
import random import random
import re import re
import signal
import sys
import threading import threading
import time import time
import uuid import uuid
@@ -16,7 +17,12 @@ import valkey
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import Flask, Request, Response, request, stream_with_context from flask import Flask, Request, Response, request, stream_with_context
lxml_html: Any = importlib.import_module("lxml.html") if os.getenv("GUNICORN_WORKER_ID"):
from gevent import monkey
monkey.patch_all()
from lxml import html as lxml_html # type: ignore[import-untyped]
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -35,6 +41,68 @@ MODEL = os.getenv("MODEL", "gpt-4o-mini")
VALKEY_URL = os.getenv("VALKEY_URL", "redis://localhost:6379/0") VALKEY_URL = os.getenv("VALKEY_URL", "redis://localhost:6379/0")
r = valkey.Valkey.from_url(VALKEY_URL, decode_responses=True) # type: ignore[assignment] r = valkey.Valkey.from_url(VALKEY_URL, decode_responses=True) # type: ignore[assignment]
class ValkeyClient:
def __init__(self, client: valkey.Valkey) -> None:
self._c = client
def get(self, key: str) -> str | None:
v = self._c.get(key) # type: ignore[union-attr]
return str(v) if isinstance(v, bytes) else v # type: ignore[return-value]
def set(self, key: str, value: str, ex: int | None = None, nx: bool = False, xx: bool = False) -> bool:
return bool(self._c.set(key, value, ex=ex, nx=nx, xx=xx)) # type: ignore[union-attr]
def delete(self, *keys: str) -> int:
return int(self._c.delete(*keys)) # type: ignore[union-attr]
def exists(self, key: str) -> bool:
return bool(self._c.exists(key)) # type: ignore[union-attr]
def rpush(self, key: str, *values: str) -> int:
return int(self._c.rpush(key, *values)) # type: ignore[union-attr]
def lpop(self, key: str) -> str | None:
v = self._c.lpop(key) # type: ignore[union-attr]
return str(v) if isinstance(v, bytes) else v # type: ignore[return-value]
def lrange(self, key: str, start: int, end: int) -> list[str]:
v = self._c.lrange(key, start, end) # type: ignore[union-attr]
return [str(x) for x in v] # type: ignore[arg-type]
def llen(self, key: str) -> int:
return int(self._c.llen(key)) # type: ignore[union-attr]
def ttl(self, key: str) -> int:
return int(self._c.ttl(key)) # type: ignore[union-attr]
def hset(self, key: str, mapping: dict[str, str]) -> int:
return int(self._c.hset(key, mapping=mapping)) # type: ignore[union-attr]
def hgetall(self, key: str) -> dict[str, str]:
v = self._c.hgetall(key) # type: ignore[union-attr]
return {str(k): str(vv) for k, vv in v.items()} # type: ignore[arg-type]
def incr(self, key: str) -> int:
return int(self._c.incr(key)) # type: ignore[union-attr]
def expire(self, key: str, seconds: int) -> bool:
return bool(self._c.expire(key, seconds)) # type: ignore[union-attr]
rv = ValkeyClient(r)
CHUNK_SIZE = 1000
CONFIRMATION_TIMEOUT_ITERATIONS = 150
CONFIRMATION_POLL_INTERVAL = 0.1
HEARTBEAT_TIMEOUT_S = 10
HEARTBEAT_CHECK_INTERVAL_S = 2
WORKER_LOCK_TTL_S = 300
WORKER_LOCK_HEARTBEAT_INTERVAL_S = 60
PROCESSING_LOCK_TTL_S = 120
HB_KEY_TTL_S = 15
REQUEST_CTX_TTL_S = 30
worker_running = False worker_running = False
worker_thread: threading.Thread | None = None worker_thread: threading.Thread | None = None
@@ -88,6 +156,24 @@ body {
color: #6366f1; color: #6366f1;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.retry-btn {
margin-top: 20px;
padding: 10px 24px;
background: rgba(99, 102, 241, 0.2);
color: #e0e0e0;
border: 1px solid rgba(99, 102, 241, 0.5);
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
font-family: system-ui, -apple-system, sans-serif;
display: none;
transition: all 0.15s ease;
}
.retry-btn:hover {
background: rgba(99, 102, 241, 0.4);
border-color: #6366f1;
color: #fff;
}
.particles { .particles {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -116,9 +202,11 @@ body {
<div class="title">Crafting your page...</div> <div class="title">Crafting your page...</div>
<div class="subtitle">The AI is designing something unique for you</div> <div class="subtitle">The AI is designing something unique for you</div>
<div class="stats" id="stats">Characters: 0</div> <div class="stats" id="stats">Characters: 0</div>
<button class="retry-btn" id="retryBtn">Retry</button>
<script> <script>
const stats = document.getElementById('stats'); const stats = document.getElementById('stats');
const titleEl = document.querySelector('.title'); const titleEl = document.querySelector('.title');
const retryBtn = document.getElementById('retryBtn');
const particles = document.getElementById('particles'); const particles = document.getElementById('particles');
for (let i = 0; i < 30; i++) { for (let i = 0; i < 30; i++) {
const p = document.createElement('div'); const p = document.createElement('div');
@@ -128,22 +216,58 @@ for (let i = 0; i < 30; i++) {
p.style.animationDuration = (2 + Math.random() * 2) + 's'; p.style.animationDuration = (2 + Math.random() * 2) + 's';
particles.appendChild(p); particles.appendChild(p);
} }
retryBtn.onclick = () => { window.location.href = '/'; };
const start = Date.now(); const start = Date.now();
const es = new EventSource('/stream' + window.location.search + window.location.hash); const es = new EventSource('/stream' + window.location.search + window.location.hash);
let chars = 0; let chars = 0;
let htmlChunks = []; let htmlChunks = [];
let htmlSize = 0; let htmlSize = 0;
let mode = 'idle'; let mode = 'idle';
let requestId = null;
let heartbeatInterval = null;
let waitingConfirmStart = null;
let statusStart = null;
const interval = setInterval(() => { const interval = setInterval(() => {
if (mode === 'generating') { if (mode === 'generating') {
stats.textContent = 'Characters: ' + chars.toLocaleString() + ' | ' + ((Date.now() - start) / 1000).toFixed(1) + 's'; stats.textContent = 'Characters: ' + chars.toLocaleString() + ' | ' + ((Date.now() - start) / 1000).toFixed(1) + 's';
} }
if (mode === 'waiting_confirmation' && waitingConfirmStart && Date.now() - waitingConfirmStart > 10000) {
es.close();
clearInterval(interval);
clearInterval(heartbeatInterval);
clearTimeout(timeout);
titleEl.textContent = 'Connection lost';
document.querySelector('.subtitle').textContent = 'Could not connect to worker. Please refresh.';
retryBtn.style.display = 'inline-block';
}
if (mode === 'status' && statusStart && Date.now() - statusStart > 60000) {
es.close();
clearInterval(interval);
clearInterval(heartbeatInterval);
clearTimeout(timeout);
titleEl.textContent = 'Connection lost';
document.querySelector('.subtitle').textContent = 'Could not connect to worker. Please refresh.';
retryBtn.style.display = 'inline-block';
}
}, 100); }, 100);
const timeout = setTimeout(() => {
es.close();
clearInterval(interval);
clearInterval(heartbeatInterval);
titleEl.textContent = 'Request timed out';
document.querySelector('.subtitle').textContent = 'Generation took too long. Please try again.';
retryBtn.style.display = 'inline-block';
}, 120000);
window.__wotf_html = ''; window.__wotf_html = '';
es.onmessage = (e) => { es.onmessage = (e) => {
if (e.data === '[DONE]') { if (e.data === '[DONE]') {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
titleEl.textContent = 'Validating HTML...'; titleEl.textContent = 'Validating HTML...';
} else if (e.data === '[RENDER]') { } else if (e.data === '[RENDER]') {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
clearTimeout(timeout);
clearInterval(interval); clearInterval(interval);
es.close(); es.close();
titleEl.textContent = 'Rendering...'; titleEl.textContent = 'Rendering...';
@@ -157,19 +281,49 @@ es.onmessage = (e) => {
htmlChunks = []; htmlChunks = [];
htmlSize = 0; htmlSize = 0;
chars = 0; chars = 0;
} else if (e.data.startsWith('[ID:')) {
requestId = e.data.slice(4, -1);
} else if (e.data.startsWith('[STATUS:')) { } else if (e.data.startsWith('[STATUS:')) {
const msg = e.data.slice(8); const msg = e.data.slice(8);
if (mode !== 'generating') { const cleanMsg = msg.endsWith(']') ? msg.slice(0, -1) : msg;
stats.textContent = msg; if (cleanMsg === 'waiting_confirmation') {
mode = 'waiting_confirmation';
waitingConfirmStart = Date.now();
if (requestId && !window.__confirmed) {
window.__confirmed = true;
fetch('/confirm/' + requestId, { method: 'POST' }).catch(() => {
es.close();
clearInterval(interval);
clearInterval(heartbeatInterval);
clearTimeout(timeout);
titleEl.textContent = 'Connection to worker failed';
document.querySelector('.subtitle').textContent = 'Could not connect to generation worker. Please refresh.';
retryBtn.style.display = 'inline-block';
});
}
} else if (mode !== 'generating') {
stats.textContent = cleanMsg;
mode = 'status'; mode = 'status';
waitingConfirmStart = null;
statusStart = Date.now();
} }
} else if (e.data === '[ERROR]') { } else if (e.data === '[ERROR]') {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
clearTimeout(timeout);
clearInterval(interval); clearInterval(interval);
es.close(); es.close();
titleEl.textContent = 'Error generating page'; titleEl.textContent = 'Error generating page';
document.querySelector('.subtitle').textContent = 'Please refresh and try again'; document.querySelector('.subtitle').textContent = 'Please refresh and try again';
retryBtn.style.display = 'inline-block';
} else { } else {
mode = 'generating'; mode = 'generating';
statusStart = null;
if (!heartbeatInterval && requestId) {
heartbeatInterval = setInterval(() => {
fetch('/heartbeat/' + requestId, { method: 'POST' });
}, 3000);
}
const bytes = Uint8Array.from(atob(e.data), c => c.charCodeAt(0)); const bytes = Uint8Array.from(atob(e.data), c => c.charCodeAt(0));
const decoded = new TextDecoder().decode(bytes); const decoded = new TextDecoder().decode(bytes);
htmlChunks.push(decoded); htmlChunks.push(decoded);
@@ -178,10 +332,14 @@ es.onmessage = (e) => {
} }
}; };
es.onerror = () => { es.onerror = () => {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
clearTimeout(timeout);
clearInterval(interval); clearInterval(interval);
es.close(); es.close();
titleEl.textContent = 'Connection error'; titleEl.textContent = 'Connection error';
document.querySelector('.subtitle').textContent = 'Please refresh and try again'; document.querySelector('.subtitle').textContent = 'Please refresh and try again';
retryBtn.style.display = 'inline-block';
}; };
function injectMenu() { function injectMenu() {
@@ -299,6 +457,10 @@ function injectMenu() {
</html>""" </html>"""
def make_httpx_client() -> httpx.Client:
return httpx.Client(timeout=300)
def call_llm(prompt: str, system: str = "You are a helpful assistant.") -> str: def call_llm(prompt: str, system: str = "You are a helpful assistant.") -> str:
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
payload: dict[str, Any] = { payload: dict[str, Any] = {
@@ -311,7 +473,7 @@ def call_llm(prompt: str, system: str = "You are a helpful assistant.") -> str:
"stream": False, "stream": False,
} }
try: try:
with httpx.Client(timeout=300) as client: with make_httpx_client() as client:
resp = client.post(API_URL, headers=headers, json=payload) resp = client.post(API_URL, headers=headers, json=payload)
resp.raise_for_status() resp.raise_for_status()
data: dict[str, Any] = resp.json() data: dict[str, Any] = resp.json()
@@ -328,16 +490,16 @@ def validate_html(html: str) -> list[str]:
errors.append("Missing DOCTYPE") errors.append("Missing DOCTYPE")
try: try:
doc: Any = lxml_html.fromstring(html) doc: Any = lxml_html.fromstring(html) # type: ignore[union-attr]
html_tags: list[Any] = doc.xpath("//html") html_tags: list[Any] = doc.xpath("//html") # type: ignore[assignment,union-attr]
body_tags: list[Any] = doc.xpath("//body") body_tags: list[Any] = doc.xpath("//body") # type: ignore[assignment,union-attr]
head_tags: list[Any] = doc.xpath("//head") head_tags: list[Any] = doc.xpath("//head") # type: ignore[assignment,union-attr]
if len(html_tags) != 1: if len(html_tags) != 1: # type: ignore[arg-type]
errors.append(f"Expected 1 <html> tag, found {len(html_tags)}") errors.append(f"Expected 1 <html> tag, found {len(html_tags)}") # type: ignore[arg-type]
if len(body_tags) != 1: if len(body_tags) != 1: # type: ignore[arg-type]
errors.append(f"Expected 1 <body> tag, found {len(body_tags)}") errors.append(f"Expected 1 <body> tag, found {len(body_tags)}") # type: ignore[arg-type]
if len(head_tags) != 1: if len(head_tags) != 1: # type: ignore[arg-type]
errors.append(f"Expected 1 <head> tag, found {len(head_tags)}") errors.append(f"Expected 1 <head> tag, found {len(head_tags)}") # type: ignore[arg-type]
except BaseException as e: # noqa: BLE001 except BaseException as e: # noqa: BLE001
errors.append(f"HTML parse error: {e}") errors.append(f"HTML parse error: {e}")
@@ -356,15 +518,18 @@ def validate_html(html: str) -> list[str]:
errors.append(f"JS block {i + 1}: mismatched brackets") errors.append(f"JS block {i + 1}: mismatched brackets")
if script_blocks: if script_blocks:
js_text = "\n".join(script_blocks) try:
review = call_llm( js_text = "\n".join(script_blocks)
f"Review this JavaScript for runtime errors (undefined variables, wrong selectors, syntax issues). List each issue concisely. If no issues, say 'No issues found.'\n\n<code>\n{js_text}\n</code>" review = call_llm(
) f"Review this JavaScript for runtime errors (undefined variables, wrong selectors, syntax issues). List each issue concisely. If no issues, say 'No issues found.'\n\n<code>\n{js_text}\n</code>"
if review and "no issue" not in review.lower(): )
for line in review.strip().split("\n"): if review and "no issue" not in review.lower():
line = line.strip() for line in review.strip().split("\n"):
if line and not line.startswith("<"): line = line.strip()
errors.append(f"JS review: {line}") if line and not line.startswith("<"):
errors.append(f"JS review: {line}")
except BaseException as e: # noqa: BLE001
logger.warning(f"JS review failed: {e}")
return errors return errors
@@ -481,94 +646,222 @@ THEMES = [
def enqueue_request(context: dict[str, Any]) -> tuple[str, int]: def enqueue_request(context: dict[str, Any]) -> tuple[str, int]:
request_id = uuid.uuid4().hex[:12] request_id = uuid.uuid4().hex[:12]
ctx_key = f"wotf:req:{request_id}:ctx" ctx_key = f"wotf:req:{request_id}:ctx"
r.hset(ctx_key, mapping={k: json.dumps(v) for k, v in context.items()}) # type: ignore[union-attr] rv.hset(ctx_key, mapping={k: json.dumps(v) for k, v in context.items()})
r.set(f"wotf:req:{request_id}:status", "pending") # type: ignore[union-attr] rv.set(f"wotf:req:{request_id}:status", "pending")
r.rpush("wotf:queue", request_id) # type: ignore[union-attr] rv.rpush("wotf:queue", request_id)
position_raw = r.llen("wotf:queue") # type: ignore[union-attr] position = rv.llen("wotf:queue")
position: int = int(position_raw) if isinstance(position_raw, (int, float)) else 0
return (request_id, position) return (request_id, position)
def get_queue_info(request_id: str) -> dict[str, Any]: def get_queue_info(request_id: str) -> dict[str, Any]:
queue_items_raw = r.lrange("wotf:queue", 0, -1) # type: ignore[union-attr] queue_items = rv.lrange("wotf:queue", 0, -1)
queue_items: list[str] = list(queue_items_raw) if isinstance(queue_items_raw, list) else [] # type: ignore[arg-type]
if request_id in queue_items: if request_id in queue_items:
position = queue_items.index(request_id) + 1 position = queue_items.index(request_id) + 1
else: else:
position = 0 position = 0
avg_raw = r.get("wotf:stats:avg_time") # type: ignore[union-attr] avg_raw = rv.get("wotf:stats:avg_time")
avg_str: str = str(avg_raw) if avg_raw is not None else "15" avg_str: str = str(avg_raw) if avg_raw is not None else "15"
avg = float(avg_str) avg = float(avg_str)
eta = position * avg eta = position * avg
return {"position": position, "avg_time": avg, "eta": eta} return {"position": position, "avg_time": avg, "eta": eta}
def get_generated_html(request_id: str) -> str:
result = r.get(f"wotf:req:{request_id}:html") # type: ignore[union-attr]
return result if isinstance(result, str) else ""
def worker_loop() -> None: def worker_loop() -> None:
worker_id = f"worker-{os.getpid()}-{threading.current_thread().name}"
hb_key = "wotf:processing:hb"
lock_hb_start = time.time()
while worker_running: while worker_running:
request_id: str | None = None request_id: str | None = None
try: try:
current = r.getset("wotf:processing", "locked") # type: ignore[union-attr] lock_holder = rv.get("wotf:processing")
if current is not None: if lock_holder is not None:
hb_ttl = rv.ttl(hb_key)
if hb_ttl == -2:
logger.info(f"[worker] Reclaiming stale lock from {lock_holder}")
rv.delete("wotf:processing")
rv.delete(hb_key)
else:
time.sleep(0.5)
continue
acquired = rv.set("wotf:processing", worker_id, ex=PROCESSING_LOCK_TTL_S, nx=True)
if not acquired:
time.sleep(0.5) time.sleep(0.5)
continue continue
request_id_raw = r.lpop("wotf:queue") # type: ignore[union-attr] rv.set(hb_key, worker_id, ex=HB_KEY_TTL_S)
if request_id_raw is None:
r.delete("wotf:processing") # type: ignore[union-attr]
time.sleep(0.5)
continue
request_id = request_id_raw if isinstance(request_id_raw, str) else None request_id = rv.lpop("wotf:queue")
if request_id is None: if request_id is None:
r.delete("wotf:processing") # type: ignore[union-attr] rv.delete("wotf:processing")
rv.delete(hb_key)
time.sleep(0.5) time.sleep(0.5)
continue continue
r.set(f"wotf:req:{request_id}:status", "processing") # type: ignore[union-attr] rv.set(f"wotf:req:{request_id}:status", "waiting_confirmation")
r.delete(f"wotf:stream:{request_id}:chunks") # type: ignore[union-attr] rv.delete(f"wotf:stream:{request_id}:chunks")
ctx_raw = r.hgetall(f"wotf:req:{request_id}:ctx") # type: ignore[union-attr] rv.rpush(f"wotf:stream:{request_id}:chunks", f"[ID:{request_id}]")
rv.rpush(f"wotf:stream:{request_id}:chunks", "[STATUS:waiting_confirmation]")
rv.delete("wotf:processing")
rv.delete(hb_key)
confirmed = False
for _ in range(CONFIRMATION_TIMEOUT_ITERATIONS):
time.sleep(CONFIRMATION_POLL_INTERVAL)
if rv.get(f"wotf:req:{request_id}:confirmed"):
confirmed = True
break
if not confirmed:
logger.info(f"[worker] No confirmation for {request_id}, marking as error")
rv.set(f"wotf:req:{request_id}:status", "error")
rv.set(f"wotf:req:{request_id}:error", "Worker did not receive confirmation from client")
rv.rpush(f"wotf:stream:{request_id}:chunks", "[STATUS:Connection to worker failed]")
rv.rpush(f"wotf:stream:{request_id}:chunks", "[ERROR]")
rv.delete(f"wotf:req:{request_id}:confirmed")
rv.expire(f"wotf:req:{request_id}:ctx", REQUEST_CTX_TTL_S)
time.sleep(0.5)
continue
acquired = False
for _ in range(5):
if rv.set("wotf:processing", worker_id, ex=PROCESSING_LOCK_TTL_S, nx=True):
acquired = True
break
time.sleep(0.5)
if not acquired:
logger.error(f"[worker] Failed to acquire lock for {request_id} after retries")
rv.set(f"wotf:req:{request_id}:status", "pending")
rv.delete(f"wotf:stream:{request_id}:chunks")
rv.delete(f"wotf:req:{request_id}:confirmed")
rv.rpush("wotf:queue", request_id)
continue
rv.set(hb_key, worker_id, ex=HB_KEY_TTL_S)
rv.set(f"wotf:req:{request_id}:status", "processing")
rv.set(f"wotf:req:{request_id}:last_heartbeat", "0")
ctx_raw = rv.hgetall(f"wotf:req:{request_id}:ctx")
ctx: dict[str, Any] = {} ctx: dict[str, Any] = {}
if isinstance(ctx_raw, dict): for k_str, v_raw in ctx_raw.items():
for k_str, v_raw in ctx_raw.items(): # type: ignore[union-attr] k: str = k_str
k: str = str(k_str) # type: ignore[arg-type] v: Any = v_raw
v: Any = v_raw # type: ignore[assignment] try:
try: ctx[k] = json.loads(v) if isinstance(v, str) else v
ctx[k] = json.loads(v) if isinstance(v, str) else v except json.JSONDecodeError:
except json.JSONDecodeError:
ctx[k] = v ctx[k] = v
start = time.time() start = time.time()
for chunk in stream_llm(ctx): hb_start = time.time()
encoded = base64.b64encode(chunk.encode("utf-8")).decode("ascii") hb_check_start = time.time()
r.rpush(f"wotf:stream:{request_id}:chunks", encoded) # type: ignore[union-attr] html_buffer: list[str] = []
elapsed = time.time() - start aborted = False
r.set(f"wotf:req:{request_id}:status", "done") # type: ignore[union-attr] try:
for chunk in stream_llm(ctx):
if time.time() - hb_start >= 5:
rv.set(hb_key, worker_id, ex=HB_KEY_TTL_S)
hb_start = time.time()
n_raw = r.get("wotf:stats:gen_count") # type: ignore[union-attr] if time.time() - hb_check_start >= HEARTBEAT_CHECK_INTERVAL_S:
hb_check_start = time.time()
last_hb_raw = rv.get(f"wotf:req:{request_id}:last_heartbeat")
if last_hb_raw is None:
logger.info(f"[worker] Heartbeat key missing, aborting {request_id}")
rv.set(f"wotf:req:{request_id}:status", "aborted")
rv.rpush(f"wotf:stream:{request_id}:chunks", "[ERROR]")
rv.delete("wotf:processing")
rv.delete(hb_key)
aborted = True
break
last_hb = float(last_hb_raw) # type: ignore[arg-type]
if last_hb > 0 and time.time() - last_hb > HEARTBEAT_TIMEOUT_S:
logger.info(f"[worker] Client disconnected (heartbeat stale), aborting {request_id}")
rv.set(f"wotf:req:{request_id}:status", "aborted")
rv.rpush(f"wotf:stream:{request_id}:chunks", "[ERROR]")
rv.delete("wotf:processing")
rv.delete(hb_key)
aborted = True
break
html_buffer.append(chunk)
encoded = base64.b64encode(chunk.encode("utf-8")).decode("ascii")
rv.rpush(f"wotf:stream:{request_id}:chunks", encoded)
except BaseException as e: # noqa: BLE001
elapsed = time.time() - start
logger.error(f"[worker] stream_llm failed for {request_id} in {elapsed:.1f}s: {e}")
retries = rv.incr(f"wotf:req:{request_id}:retries")
if retries <= 2:
logger.info(f"[worker] Requeuing {request_id} (attempt {retries}/2)")
rv.set(f"wotf:req:{request_id}:status", "pending")
rv.delete(f"wotf:stream:{request_id}:chunks")
rv.rpush("wotf:queue", request_id)
else:
logger.error(f"[worker] {request_id} failed after {retries} attempts")
rv.set(f"wotf:req:{request_id}:status", "error")
rv.set(f"wotf:req:{request_id}:error", str(e))
rv.delete("wotf:processing")
rv.delete(hb_key)
time.sleep(1)
continue
if aborted:
time.sleep(1)
continue
html = "".join(html_buffer)
rv.rpush(f"wotf:stream:{request_id}:chunks", "[STATUS:Validating HTML structure...]")
errors = validate_html(html)
if errors:
short = summarize_errors(errors)
rv.rpush(f"wotf:stream:{request_id}:chunks", f"[STATUS:Found {len(errors)} issue(s): {short}. Fixing (attempt 1/2)...]")
html = fix_html(html, errors)
errors = validate_html(html)
if errors:
short = summarize_errors(errors)
rv.rpush(f"wotf:stream:{request_id}:chunks", "[STATUS:Still invalid. Fixing (attempt 2/2)...]")
html = fix_html(html, errors)
errors = validate_html(html)
if errors:
short = summarize_errors(errors)
rv.rpush(f"wotf:stream:{request_id}:chunks", f"[STATUS:Still has {len(errors)} issue(s): {short}. Serving as-is.]")
else:
rv.rpush(f"wotf:stream:{request_id}:chunks", "[STATUS:Fixed successfully.]")
rv.rpush(f"wotf:stream:{request_id}:chunks", "[CLEAR]")
for i in range(0, len(html), CHUNK_SIZE):
chunk = html[i : i + CHUNK_SIZE]
encoded = base64.b64encode(chunk.encode("utf-8")).decode("ascii")
rv.rpush(f"wotf:stream:{request_id}:chunks", encoded)
else:
rv.rpush(f"wotf:stream:{request_id}:chunks", "[STATUS:Validation passed.]")
rv.rpush(f"wotf:stream:{request_id}:chunks", "[RENDER]")
elapsed = time.time() - start
rv.set(f"wotf:req:{request_id}:status", "done")
n_raw = rv.get("wotf:stats:gen_count")
n_str: str = str(n_raw) if n_raw is not None else "0" n_str: str = str(n_raw) if n_raw is not None else "0"
n = int(n_str) n = int(n_str)
avg_raw = r.get("wotf:stats:avg_time") # type: ignore[union-attr] avg_raw = rv.get("wotf:stats:avg_time")
avg_str: str = str(avg_raw) if avg_raw is not None else "15" avg_str: str = str(avg_raw) if avg_raw is not None else "15"
avg = float(avg_str) avg = float(avg_str)
new_avg = (avg * n + elapsed) / (n + 1) if n > 0 else elapsed new_avg = (avg * n + elapsed) / (n + 1) if n > 0 else elapsed
r.set("wotf:stats:avg_time", str(new_avg)) # type: ignore[union-attr] rv.set("wotf:stats:avg_time", str(new_avg))
r.set("wotf:stats:gen_count", str(n + 1)) # type: ignore[union-attr] rv.set("wotf:stats:gen_count", str(n + 1))
r.delete("wotf:processing") # type: ignore[union-attr] rv.delete("wotf:processing")
rv.delete(hb_key)
logger.info(f"[worker] Generated {request_id} in {elapsed:.1f}s (avg={new_avg:.1f}s)") logger.info(f"[worker] Generated {request_id} in {elapsed:.1f}s (avg={new_avg:.1f}s)")
if time.time() - lock_hb_start >= WORKER_LOCK_HEARTBEAT_INTERVAL_S:
lock_hb_start = time.time()
rv.set("wotf:worker_lock", "1", xx=True, ex=WORKER_LOCK_TTL_S)
except BaseException as e: # noqa: BLE001 except BaseException as e: # noqa: BLE001
logger.error(f"[worker] Error: {e}") logger.error(f"[worker] Error: {e}")
if request_id: if request_id:
r.set(f"wotf:req:{request_id}:status", "error") # type: ignore[union-attr] rv.set(f"wotf:req:{request_id}:status", "error")
r.set(f"wotf:req:{request_id}:error", str(e)) # type: ignore[union-attr] rv.set(f"wotf:req:{request_id}:error", str(e))
r.delete("wotf:processing") # type: ignore[union-attr] rv.delete("wotf:processing")
rv.delete(hb_key)
time.sleep(1) time.sleep(1)
@@ -630,7 +923,7 @@ def stream_llm(context: dict[str, Any]) -> Generator[str]:
logger.info(f"[{req_id}] Streaming LLM call for IP={context['ip']}, lang={context['language']}") logger.info(f"[{req_id}] Streaming LLM call for IP={context['ip']}, lang={context['language']}")
try: try:
with httpx.Client(timeout=300) as client, client.stream("POST", API_URL, headers=headers, json=payload) as resp: with make_httpx_client() as client, client.stream("POST", API_URL, headers=headers, json=payload) as resp:
elapsed = time.time() - start elapsed = time.time() - start
resp.raise_for_status() resp.raise_for_status()
buffer = "" buffer = ""
@@ -657,58 +950,33 @@ def stream_llm(context: dict[str, Any]) -> Generator[str]:
elapsed = time.time() - start elapsed = time.time() - start
logger.error(f"[{req_id}] HTTP error in {elapsed:.1f}s: {e.response.status_code} {e.response.text[:200]}") logger.error(f"[{req_id}] HTTP error in {elapsed:.1f}s: {e.response.status_code} {e.response.text[:200]}")
yield f"\n\n<!-- Error: {e.response.status_code} -->" yield f"\n\n<!-- Error: {e.response.status_code} -->"
except BaseException as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
elapsed = time.time() - start elapsed = time.time() - start
logger.error(f"[{req_id}] Stream failed in {elapsed:.1f}s: {e}") logger.error(f"[{req_id}] Stream failed in {elapsed:.1f}s: {e}")
yield f"\n\n<!-- Error: {e} -->" yield f"\n\n<!-- Error: {e} -->"
def sse_stream(context: dict[str, Any]) -> Generator[str]: @app.route("/health")
buffer: list[str] = [] def health() -> Response:
try:
valkey_ok = rv.get("wotf:health_check") is not None or rv.set("wotf:health_check", "1", ex=60)
except BaseException: # noqa: BLE001
valkey_ok = False
def status(msg: str) -> str: queue_depth = rv.llen("wotf:queue")
return f"data: [STATUS:{msg}]\n\n" worker_active = rv.exists("wotf:worker_lock")
yield status("Generating HTML...") status = "ok" if (valkey_ok and worker_active) else "degraded"
for chunk in stream_llm(context):
buffer.append(chunk)
encoded = base64.b64encode(chunk.encode("utf-8")).decode("ascii")
yield f"data: {encoded}\n\n"
html = "".join(buffer) return Response(
yield "data: [DONE]\n\n" json.dumps({
"status": status,
yield status("Validating HTML structure...") "valkey_connected": valkey_ok,
errors = validate_html(html) "worker_active": worker_active,
"queue_depth": queue_depth,
if errors: }),
short = summarize_errors(errors) mimetype="application/json",
yield status(f"Found {len(errors)} issue(s): {short}. Fixing (attempt 1/2)...") )
html = fix_html(html, errors)
errors = validate_html(html)
if errors:
short = summarize_errors(errors)
yield status("Still invalid. Fixing (attempt 2/2)...")
html = fix_html(html, errors)
errors = validate_html(html)
if errors:
short = summarize_errors(errors)
yield status(f"Still has {len(errors)} issue(s): {short}. Serving as-is.")
else:
yield status("Fixed successfully.")
yield "data: [CLEAR]\n\n"
for i in range(0, len(html), 1000):
chunk = html[i : i + 1000]
encoded = base64.b64encode(chunk.encode("utf-8")).decode("ascii")
yield f"data: {encoded}\n\n"
else:
yield status("Validation passed.")
yield "data: [RENDER]\n\n"
@app.route("/", defaults={"path": ""}) @app.route("/", defaults={"path": ""})
@@ -729,57 +997,90 @@ def stream() -> Response:
def generate() -> Generator[str]: def generate() -> Generator[str]:
yield "data: [CLEAR]\n\n" yield "data: [CLEAR]\n\n"
last_idx = 0
while True: while True:
status_raw = r.get(f"wotf:req:{request_id}:status") # type: ignore[union-attr] status_raw = rv.get(f"wotf:req:{request_id}:status")
status = status_raw if isinstance(status_raw, str) else "pending" status = status_raw if isinstance(status_raw, str) else "pending"
if status == "error": if status == "error":
error_raw = r.get(f"wotf:req:{request_id}:error") # type: ignore[union-attr] error_raw = rv.get(f"wotf:req:{request_id}:error")
error_msg = error_raw if isinstance(error_raw, str) else "Unknown error" error_msg = error_raw if isinstance(error_raw, str) else "Unknown error"
yield f"data: [STATUS:Error: {error_msg}\n\n" yield f"data: [STATUS:Error: {error_msg}]\n\n"
yield "data: [ERROR]\n\n" yield "data: [ERROR]\n\n"
return return
if status == "done": if status == "waiting_confirmation":
break chunks_raw = rv.lrange(f"wotf:stream:{request_id}:chunks", 0, -1)
if status == "processing":
chunks_raw = r.lrange(f"wotf:stream:{request_id}:chunks", 0, -1) # type: ignore[union-attr]
chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type] chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type]
if chunks: if chunks:
break for c in chunks:
yield "data: [STATUS:Generating your page...\n\n" yield f"data: {c}\n\n"
time.sleep(0.5) else:
yield "data: [STATUS:Waiting for your connection...]\n\n"
time.sleep(0.2)
continue continue
if status == "processing":
chunks_raw = rv.lrange(f"wotf:stream:{request_id}:chunks", last_idx, -1)
chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type]
if chunks:
for c in chunks:
yield f"data: {c}\n\n"
last_idx += len(chunks)
else:
yield "data: [STATUS:Generating your page...]\n\n"
time.sleep(0.1)
continue
if status == "done":
chunks_raw = rv.lrange(f"wotf:stream:{request_id}:chunks", last_idx, -1)
chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type]
for c in chunks:
yield f"data: {c}\n\n"
break
info = get_queue_info(request_id) info = get_queue_info(request_id)
pos = info["position"] pos = info["position"]
eta = info["eta"] eta = info["eta"]
if pos == 0: if pos == 0:
yield "data: [STATUS:Your turn is next...\n\n" yield "data: [STATUS:Your turn is next...]\n\n"
else: else:
yield f"data: [STATUS:Position {pos} in line | Est. wait: ~{eta:.0f}s\n\n" yield f"data: [STATUS:Position {pos} in line | Est. wait: ~{eta:.0f}s]\n\n"
time.sleep(1) time.sleep(1)
last_idx = 0
while True: while True:
chunks_raw = r.lrange(f"wotf:stream:{request_id}:chunks", last_idx, -1) # type: ignore[union-attr] chunks_raw = rv.lrange(f"wotf:stream:{request_id}:chunks", last_idx, -1)
chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type] chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type]
if chunks: if chunks:
for chunk in chunks: for chunk in chunks:
if chunk == "[RENDER]":
yield f"data: {chunk}\n\n"
return
yield f"data: {chunk}\n\n" yield f"data: {chunk}\n\n"
last_idx += len(chunks) last_idx += len(chunks)
time.sleep(0.05) time.sleep(0.05)
else: else:
status_raw = r.get(f"wotf:req:{request_id}:status") # type: ignore[union-attr] status_raw = rv.get(f"wotf:req:{request_id}:status")
status = status_raw if isinstance(status_raw, str) else "pending" status = status_raw if isinstance(status_raw, str) else "pending"
if status == "done": if status == "done":
chunks_raw = rv.lrange(f"wotf:stream:{request_id}:chunks", last_idx, -1)
chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type]
if chunks:
for chunk in chunks:
yield f"data: {chunk}\n\n"
break break
if status == "error":
error_raw = rv.get(f"wotf:req:{request_id}:error")
error_msg = error_raw if isinstance(error_raw, str) else "Unknown error"
yield f"data: [STATUS:Error: {error_msg}]\n\n"
yield "data: [ERROR]\n\n"
return
if status == "aborted":
yield "data: [STATUS:Generation aborted (connection lost)]\n\n"
yield "data: [ERROR]\n\n"
return
time.sleep(0.2) time.sleep(0.2)
yield "data: [DONE]\n\n"
yield "data: [RENDER]\n\n"
return Response( return Response(
stream_with_context(generate()), stream_with_context(generate()),
mimetype="text/event-stream", mimetype="text/event-stream",
@@ -791,16 +1092,40 @@ def stream() -> Response:
) )
@app.route("/confirm/<request_id>", methods=["POST"])
def confirm(request_id: str) -> Response:
ctx = rv.exists(f"wotf:req:{request_id}:ctx")
if not ctx:
return Response("not found", status=404)
rv.set(f"wotf:req:{request_id}:confirmed", "1")
return Response("ok", status=200)
@app.route("/heartbeat/<request_id>", methods=["POST"])
def heartbeat(request_id: str) -> Response:
status = rv.get(f"wotf:req:{request_id}:status")
if status is None:
return Response("not found", status=404)
rv.set(f"wotf:req:{request_id}:last_heartbeat", str(time.time()))
return Response("ok", status=200)
def start_worker_if_needed() -> None: def start_worker_if_needed() -> None:
global worker_running, worker_thread global worker_running, worker_thread
if worker_running: if worker_running:
return return
claimed = r.set("wotf:worker_lock", "1", nx=True, ex=300) # type: ignore[union-attr] claimed = rv.set("wotf:worker_lock", "1", nx=True, ex=WORKER_LOCK_TTL_S)
if claimed: if claimed:
worker_running = True worker_running = True
worker_thread = threading.Thread(target=worker_loop, daemon=True) if os.getenv("GUNICORN_WORKER_ID"):
worker_thread.start() from gevent import spawn
logger.info("[startup] Worker thread started in this process")
worker_thread = spawn(worker_loop) # type: ignore[assignment]
logger.info("[startup] Worker greenlet started in this process")
else:
worker_thread = threading.Thread(target=worker_loop, daemon=True)
worker_thread.start()
logger.info("[startup] Worker thread started in this process")
else: else:
logger.info("[startup] Worker thread already running in another process") logger.info("[startup] Worker thread already running in another process")
@@ -809,7 +1134,21 @@ def gunicorn_post_fork(server: Any, worker: Any) -> None:
start_worker_if_needed() start_worker_if_needed()
def graceful_shutdown(signum: int, frame: Any) -> None:
global worker_running
sig_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM"
logger.info(f"[shutdown] Received {sig_name}, stopping worker...")
worker_running = False
rv.delete("wotf:worker_lock")
rv.delete("wotf:processing")
rv.delete("wotf:processing:hb")
logger.info("[shutdown] Locks released, exiting")
sys.exit(0)
if __name__ == "__main__": if __name__ == "__main__":
signal.signal(signal.SIGINT, graceful_shutdown)
signal.signal(signal.SIGTERM, graceful_shutdown)
worker_running = True worker_running = True
worker_thread = threading.Thread(target=worker_loop, daemon=True) worker_thread = threading.Thread(target=worker_loop, daemon=True)
worker_thread.start() worker_thread.start()
+3 -1
View File
@@ -3,7 +3,9 @@ from typing import Any
import app import app
bind = "0.0.0.0:5000" bind = "0.0.0.0:5000"
workers = 2 workers = 16
worker_class = "gevent"
worker_connections = 1000
timeout = 300 timeout = 300
+2 -1
View File
@@ -1,11 +1,12 @@
[project] [project]
name = "web-on-the-fly" name = "web-on-the-fly"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "Flask app that streams AI-generated fake websites via SSE"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"flask>=3.1.3", "flask>=3.1.3",
"gevent>=24.11.1",
"gunicorn>=23.0.0", "gunicorn>=23.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"lxml>=5.0.0", "lxml>=5.0.0",
Generated
+176
View File
@@ -32,6 +32,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
] ]
[[package]]
name = "cffi"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
{ url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
{ url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
{ url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
{ url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
{ url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.4.2" version = "8.4.2"
@@ -70,6 +102,102 @@ wheels = [
{ 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" }, { 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" },
] ]
[[package]]
name = "gevent"
version = "26.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" },
{ name = "greenlet", marker = "platform_python_implementation == 'CPython'" },
{ name = "zope-event" },
{ name = "zope-interface" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/5c/92002455a57cb3634383e2b822e3bccf409f43cde34528e46428971475cf/gevent-26.7.0.tar.gz", hash = "sha256:5b333a556e38a302b1b8c80525bef16d437e16f1e7767947789406841856a102", size = 6729213, upload-time = "2026-07-22T20:16:04.713Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/63/0fcfbe3f5696e56424f331ec41e0e447cea79c384848d6019e3f7f340f4b/gevent-26.7.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b1b89eb5566f75aa8b2bbdb0308e1ac8d9113ca7cff85b45366aea9faad639a1", size = 2976844, upload-time = "2026-07-22T16:24:39.275Z" },
{ url = "https://files.pythonhosted.org/packages/3c/6c/ea2d0afbe760c18df5bd1631dbe5a73d840d9b141cb71e6810157c2ae28a/gevent-26.7.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:449857ce058183442e2d71d83ff0c587a3ddff631e93c6d19a6dffb4814eccad", size = 1802332, upload-time = "2026-07-22T18:11:29.155Z" },
{ url = "https://files.pythonhosted.org/packages/59/90/36f2258f1bfe8601224f6159066103b832c31e1451ce8dc2cd2408b6ecf4/gevent-26.7.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:8260a3f38b05fcf3c283417b18617562dbec74f5784f748e4ba3866789d7f3a4", size = 1901253, upload-time = "2026-07-22T18:10:44.402Z" },
{ url = "https://files.pythonhosted.org/packages/dd/38/86dd67e5c2dfab016a9c935b4338d6cf8f9bfa72dc5a0f3fb879d993127a/gevent-26.7.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:30894398d06747b433c8923a6a77ede61259ce6822a99f6c6e7fa0216ccb73c3", size = 1850489, upload-time = "2026-07-22T18:29:09.694Z" },
{ url = "https://files.pythonhosted.org/packages/f1/33/f5651942a5967483298b6ce6f45572d33120dd0fd01c8991d3fca5b1e8ee/gevent-26.7.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0b753522498118c9489753de7c612d4baed0edf384d9df2bf9492233ba1c20ff", size = 2129813, upload-time = "2026-07-22T16:48:32.551Z" },
{ url = "https://files.pythonhosted.org/packages/c0/09/abe8217a8fcd3f0e94c9eec024a5499c0f267cb269ccea6c0e2e812319b9/gevent-26.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:055a643026dc28daff2be228555a2097937448cc9b58307edebcf81b9d78ff4b", size = 1815121, upload-time = "2026-07-22T18:07:13.988Z" },
{ url = "https://files.pythonhosted.org/packages/40/d6/dbae1cd2d27b62664cefa086035530eb21203d45b12f466272441c048c9c/gevent-26.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e1dc6a2712de67fd210e1f1a408601f6908b042f6420e188106f2f37f94ec71", size = 2155913, upload-time = "2026-07-22T17:02:16.35Z" },
{ url = "https://files.pythonhosted.org/packages/fc/42/90b662f4eb27d7727d4619d5c6be872117f1a9f187b243ec7f6fef988ce7/gevent-26.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:44e5280296129c0915addaefdb37d6e9bc124a77a433b1b1c8ddf1853c53f4e7", size = 1682483, upload-time = "2026-07-22T16:26:30.492Z" },
{ url = "https://files.pythonhosted.org/packages/e1/84/7297c56b9fff463c4ba2f685dbb913a855df903046dc68d14e8655a29ffe/gevent-26.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f08b1aa6729f794409ca137e25f671e0d9bbda4451200c5e28a769375365388", size = 1556053, upload-time = "2026-07-22T16:26:14.365Z" },
{ url = "https://files.pythonhosted.org/packages/c1/bb/ab60d496cbdc0293ebbd6c2070b34da0632bd7a2ca20163c17e18d2d2dc9/gevent-26.7.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0e0e3bf7ae0f82dbc5c6be26b4781e86c97f1e28d516b7a9746ac8b04bcc6948", size = 2992503, upload-time = "2026-07-22T16:24:36.503Z" },
{ url = "https://files.pythonhosted.org/packages/5c/35/75f27c06a82a5b22600aaccbd9567d89bb4091be43e96c02981f10aff23d/gevent-26.7.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:740050b53048207b080a1e183a377c47809ad0b7b7b0cd7eab0dea1045f7e480", size = 1809173, upload-time = "2026-07-22T18:11:30.724Z" },
{ url = "https://files.pythonhosted.org/packages/5b/5f/a6b32b4db3fa76bd8a070f0f46f5306123bf6336e7a0ca0cd2f9b99473df/gevent-26.7.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:67983607eb6c7bafa362c5c43b69a27145b936c34a3d6441ed42413d62fae0a6", size = 1906630, upload-time = "2026-07-22T18:10:45.836Z" },
{ url = "https://files.pythonhosted.org/packages/e1/87/832495d8fcc05ff7432f038b7c4decbd5632425a2cd5da2ce73cb2d800c4/gevent-26.7.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:475848518d708e07d1987c3d94cb8ff53e2b3a69df32e39feda2779cafe400b0", size = 1855278, upload-time = "2026-07-22T18:29:11.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/8b/2f36c0fa389fa2b7ceb5a8972b0e7da7bc770f9135315cf4246c607ca5fc/gevent-26.7.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0f8ed457dd616bfe6682569f92730f9ab45aafb1aeca5e80eb2f6b9a2ce26d11", size = 2136155, upload-time = "2026-07-22T16:48:33.865Z" },
{ url = "https://files.pythonhosted.org/packages/b5/98/09f2cfaa23dbce48e3271e95b0d003f93acece6b5cfd40f4cebe3850d79b/gevent-26.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15373c68cf1fa14114bec2f09b16e2c65374bd5309e897e0a28740b09ce329e0", size = 1822108, upload-time = "2026-07-22T18:07:15.397Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d8/05a294165c17569f04284ad3c889684c8780544885b4cdf77b1432947d0c/gevent-26.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73f3d53f2f390369e290c933b75bd87f1f2261f2f2f2175aa667c43ee3049bad", size = 2162814, upload-time = "2026-07-22T17:02:18.066Z" },
{ url = "https://files.pythonhosted.org/packages/59/89/58a545c4eda33e106d6887a0387adc2249abc14c779e3eb88bbfdf3768d6/gevent-26.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:f11b558d544ad2249029ba023cd6519ec3a0eee54a3d027e6515c1eaa322422a", size = 1706971, upload-time = "2026-07-22T16:27:02.263Z" },
{ url = "https://files.pythonhosted.org/packages/bb/cd/413f293e54961e5c89c54235370e3603ec0f561e7ace8357980410efbf78/gevent-26.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:3871f4ca59ec2328c3ef638a0fe01a28a825443a133368dc78eb5ceadcad7609", size = 1585078, upload-time = "2026-07-22T16:30:48.145Z" },
{ url = "https://files.pythonhosted.org/packages/21/3a/47f29f632aaa38aa12410f57f1732fc50bfd4d4006d2e7e022ce731cabc9/gevent-26.7.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3e3d6e20a94239ad353b776e72b8ce18c35dbe4e98c279aef3932651553d8404", size = 2996208, upload-time = "2026-07-22T16:23:23.859Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b3/4620f1ce81ecec9890229806c73f07dd022e40f76a552bd430391e7316c4/gevent-26.7.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:ddbd3cc76b9bc69df651a216c2a62fc6415ad463b3ac9c6cbbbb8b7b8224af17", size = 1811545, upload-time = "2026-07-22T18:11:32.224Z" },
{ url = "https://files.pythonhosted.org/packages/97/fd/d285212ffd5585d511299e13e61d76262def8e826e9f20c92cb85df406f2/gevent-26.7.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:01ceab7e608dc1b9859d9511a0a29d7ce2e7d909ab19fddc860e70a2ed5b10ce", size = 1910418, upload-time = "2026-07-22T18:10:47.709Z" },
{ url = "https://files.pythonhosted.org/packages/2d/e0/c5d666e6065652918cfb6e6a3cf8f721d0e152c57cec217ad81792a1323b/gevent-26.7.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:2e6c917b2b8baeb6080797a6b25e35e1fd784319a05bb92b87c53546e5578eb2", size = 1857891, upload-time = "2026-07-22T18:29:13.13Z" },
{ url = "https://files.pythonhosted.org/packages/a1/67/e945ed458fa98b34572876bfd0d35fe4fa3f1159f43660b71d982b7cb63e/gevent-26.7.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:df75a1748b26030f2f7f10042cc45640b22954d9d0dc6b4b6f0dbe0b6751a2d4", size = 2138121, upload-time = "2026-07-22T16:48:35.358Z" },
{ url = "https://files.pythonhosted.org/packages/99/55/622468fa1a3c4cf51f20e14813f5cc1592fe6e44a42ccca9195a0b18c769/gevent-26.7.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ee1b389587e5d5c1eb19d0455b5b4d7a0fb5c5287af4e226ec66d9dfd2548107", size = 1825114, upload-time = "2026-07-22T18:07:16.667Z" },
{ url = "https://files.pythonhosted.org/packages/0f/7a/151a2afcacf487ca25faf8b1bdd6c5b4ace2f7c1e6b4eaffe0a5e6e1df61/gevent-26.7.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c2918641ba756f46aa01ab9dd82d6dfceec403c77c2787298746b411dcf0288e", size = 2165990, upload-time = "2026-07-22T17:02:19.817Z" },
]
[[package]]
name = "greenlet"
version = "3.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" },
{ url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" },
{ url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" },
{ url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" },
{ url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" },
{ url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" },
{ url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" },
{ url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" },
{ url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" },
{ url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" },
{ url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" },
{ url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" },
{ url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" },
{ url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" },
{ url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" },
{ url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" },
{ url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" },
{ url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" },
{ url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" },
{ url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" },
{ url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" },
{ url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" },
{ url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" },
{ url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" },
{ url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" },
{ url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" },
{ url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" },
{ url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" },
{ url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" },
{ url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" },
{ url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" },
{ url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" },
{ url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" },
{ url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" },
{ url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" },
{ url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" },
{ url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" },
{ url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" },
{ url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" },
{ url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" },
{ url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" },
]
[[package]] [[package]]
name = "gunicorn" name = "gunicorn"
version = "26.0.0" version = "26.0.0"
@@ -281,6 +409,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
] ]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]] [[package]]
name = "pyright" name = "pyright"
version = "1.1.411" version = "1.1.411"
@@ -352,6 +489,7 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "flask" }, { name = "flask" },
{ name = "gevent" },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "httpx" }, { name = "httpx" },
{ name = "lxml" }, { name = "lxml" },
@@ -368,6 +506,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "flask", specifier = ">=3.1.3" }, { name = "flask", specifier = ">=3.1.3" },
{ name = "gevent", specifier = ">=24.11.1" },
{ name = "gunicorn", specifier = ">=23.0.0" }, { name = "gunicorn", specifier = ">=23.0.0" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "lxml", specifier = ">=5.0.0" }, { name = "lxml", specifier = ">=5.0.0" },
@@ -392,3 +531,40 @@ sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd1
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
] ]
[[package]]
name = "zope-event"
version = "6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/93/41/faa10af34d48d9cd6fa0249a1162943ad84a9590bd1a06939981e6640416/zope_event-6.2.tar.gz", hash = "sha256:b97d5d6327067ee6b9dfcbdf606ade9ade70991e19c162e808ea39e5fcf0f8d3", size = 18958, upload-time = "2026-04-28T06:24:10.578Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/33/848922889e946d4befc415c219fe516af75c49555d8e736e183bfd30db42/zope_event-6.2-py3-none-any.whl", hash = "sha256:5e755153ac4faf64c10a4b6dd3307680166a3edf65b38df22df592610f8fa874", size = 6525, upload-time = "2026-04-28T06:24:09.176Z" },
]
[[package]]
name = "zope-interface"
version = "8.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/08/dc/50550cfcbb2ea3cbca5f1d7ed05c8aa840f831a0f2d63aec0a953f7c590e/zope_interface-8.5.tar.gz", hash = "sha256:7a3ba1c5877f0f3e3906b02ddf793abed2becc2948116414ce0e1dd820b68d6d", size = 257957, upload-time = "2026-05-26T06:50:14.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/8c/4c15755d701f2ec0e80d64a18e1ebaf5be2c584c0ec153fd516f5d13eada/zope_interface-8.5-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:28e80457c134d1fa57a7d758004dece348654e1b1467ac22dcdc20fc1d127c52", size = 212512, upload-time = "2026-05-26T06:49:38.996Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2e/4360c54c465db042cc8fbeeec92abac28b4cedbf6ba63c1f092fd08a190f/zope_interface-8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09495ce9d559c06b70f2d4855b3e4f48a822a9ddc8be1d30c5b4e5be14ae1ace", size = 212541, upload-time = "2026-05-26T06:49:41.186Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a5/692a2b8d70f78e848793231d5fae5fecbf8d0cccd73430fdc34802a6d3c1/zope_interface-8.5-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7849ad8fa90763cc1087f4dda78ca3a233e950b3e08fac7079297c9cafbbd7bb", size = 265191, upload-time = "2026-05-26T06:49:43.449Z" },
{ url = "https://files.pythonhosted.org/packages/70/8d/454a9cfc7a050c394ab4f11b3371f7897828b7415e096afff724637e65e0/zope_interface-8.5-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5578c9421ca409a1f39f153d6f7803e4cde01da592ec75a9ac5e1b777d18d33b", size = 270626, upload-time = "2026-05-26T06:49:45.425Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/db8409cfa3575b8e9b4800babd7d49f8228433cd1f0c56814bd0ada49c33/zope_interface-8.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e1bd7d96b4ca5fa311f54c9eac16dce4886b428c1531dbe06067763ccdf123b4", size = 270444, upload-time = "2026-05-26T06:49:47.025Z" },
{ url = "https://files.pythonhosted.org/packages/4a/df/a386940e41469ef615e100a216d8b386521e9e598817147f87932ca203c4/zope_interface-8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0c8123d2a4dfde2a613c7cb772605477724782c20bc2e0ad1d9435376a6a44a3", size = 215021, upload-time = "2026-05-26T06:49:48.478Z" },
{ url = "https://files.pythonhosted.org/packages/89/75/477eb5669b6b2a7a843decd1a075e9b1971a8720017654143a7183abd3d9/zope_interface-8.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d02be14f3173c6c7288bc2fdf530090c01c3cf8764ad46c68024686f364278e", size = 213610, upload-time = "2026-05-26T06:49:50.01Z" },
{ url = "https://files.pythonhosted.org/packages/d4/19/5032e954827fdf02db2d2f49737ac4378bb9cfc2cd95a8f2e2a5ae2ec01a/zope_interface-8.5-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:ffaecf013251a89d0de6feb49a46eba48ad8cbbf8a40aeb6045e459e7bec6784", size = 212597, upload-time = "2026-05-26T06:49:51.63Z" },
{ url = "https://files.pythonhosted.org/packages/f1/53/3ef644012cf8a6a234a2d6134aab5a5c65ac5467c86296865501d4fbc406/zope_interface-8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:126fa9d1c52295ae076d4cf968634f0a1826afa408a20808b57ff72877b8f69f", size = 212626, upload-time = "2026-05-26T06:49:53.236Z" },
{ url = "https://files.pythonhosted.org/packages/32/67/bc8b4f465d388039255003e230c284a175cedf1203c692f23cb7bff64efe/zope_interface-8.5-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:3090e3a663d20194756a59a272e0c8508b889341e31d5894223331fe6b4f9b21", size = 266827, upload-time = "2026-05-26T06:49:54.873Z" },
{ url = "https://files.pythonhosted.org/packages/a7/eb/37d05b935ede53d79690fecc8d201440084418e590bcfc05f384451c7593/zope_interface-8.5-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9342fb74e2afefdb081bf1df727d209ea56995c6e13f5a0540e6d7aff4beafb8", size = 270139, upload-time = "2026-05-26T06:49:57.116Z" },
{ url = "https://files.pythonhosted.org/packages/8b/0b/fd0c54579e2ce8dc6cf1a757903f3374bc6fbda929a46af9e0f53cb0e5f0/zope_interface-8.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c54725d818f1b57a7efb8b16528326e1f3c257b602b32393fd255c45af8799d", size = 270338, upload-time = "2026-05-26T06:49:58.698Z" },
{ url = "https://files.pythonhosted.org/packages/c1/1d/c420dcd777bb761067ea92879ac766694a5ca78608185f1aecea64cbfc11/zope_interface-8.5-cp314-cp314-win_amd64.whl", hash = "sha256:29d74febbae1afeb6834c4ccbf42e242a673c860060f09e53142825270456140", size = 215789, upload-time = "2026-05-26T06:50:00.405Z" },
{ url = "https://files.pythonhosted.org/packages/62/94/50b5eb8f94e527edceac14f9955e58917424ea79bb572ddc18548561cbc2/zope_interface-8.5-cp314-cp314-win_arm64.whl", hash = "sha256:633c8c49396f38df030340797c533e9fe460d1b5d1e42d88e55e938e525f548c", size = 213757, upload-time = "2026-05-26T06:50:01.973Z" },
{ url = "https://files.pythonhosted.org/packages/17/6f/5d5f32c4dfcdb16ce2ec5363da686840f13c13e1a1214cb70b49e1cd6d9f/zope_interface-8.5-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:133999820fdbae513c36c03d6f29ef87317aaa3edef39112222b155083664714", size = 213591, upload-time = "2026-05-26T06:50:03.529Z" },
{ url = "https://files.pythonhosted.org/packages/f3/55/de0c3459ff717fce3342f9a29464c281fdeb0d36c3171ee88d119d5f0650/zope_interface-8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8bd75c96966e573232f0599deaff717564828031c7f05563ccc1ac35c5ee0304", size = 213733, upload-time = "2026-05-26T06:50:05.101Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/d97430abd5ae9677e8b9295b58720c0064a5b557dbb6b8bf5928484cf0d8/zope_interface-8.5-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:14b0e9799351d4c34fe99afd67f0cdd76e55ba15c66a98699d5fc22ea8241e08", size = 294905, upload-time = "2026-05-26T06:50:07.384Z" },
{ url = "https://files.pythonhosted.org/packages/41/ec/a0f8f3dad6e74992f4654bdd94802be0929eabca7b871cac3b6fbb5e961b/zope_interface-8.5-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cd6a732ac84b94eb1ef9222a117347a27efd294ee16810ffdf7ecd307677ed5", size = 300885, upload-time = "2026-05-26T06:50:08.997Z" },
{ url = "https://files.pythonhosted.org/packages/0f/da/6881b48803a0ee8d23eb5efa30fce3ed218a2bd9de5758ce489d224fee81/zope_interface-8.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:798b7c87d0e59a7d5d086d642208d0d8700ff0d55c4029134b3c479c3bfb110f", size = 304672, upload-time = "2026-05-26T06:50:10.563Z" },
{ url = "https://files.pythonhosted.org/packages/2e/0e/b4c01320859ff1d585438bc231fd60bd258d096359bccf6654fecdf0cffb/zope_interface-8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc3a9d45f114d27eaa1e53beeb144533689edca8a9f66505b1e8e8b3f075e42", size = 217241, upload-time = "2026-05-26T06:50:12.171Z" },
]