From 6877d8710ec1806845af9442ced56673fe89180e Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:16:30 +0200 Subject: [PATCH 01/12] feat(temporal): validation service scoring alert windows with the ONNX temporal model POST /jobs takes a window of frames plus YOLO boxes for one camera pose and returns a job id at once; a worker thread scores it with OnnxTemporalModel and GET /jobs/{id} serves the verdict. No torch. --- pyro_temporal_api/Dockerfile | 25 + pyro_temporal_api/pyproject.toml | 25 + .../pyro_temporal_api/__init__.py | 4 + pyro_temporal_api/pyro_temporal_api/jobs.py | 132 ++++ pyro_temporal_api/pyro_temporal_api/main.py | 91 +++ pyro_temporal_api/requirements.txt | 101 +++ pyro_temporal_api/tests/test_jobs.py | 124 ++++ pyro_temporal_api/uv.lock | 695 ++++++++++++++++++ 8 files changed, 1197 insertions(+) create mode 100644 pyro_temporal_api/Dockerfile create mode 100644 pyro_temporal_api/pyproject.toml create mode 100644 pyro_temporal_api/pyro_temporal_api/__init__.py create mode 100644 pyro_temporal_api/pyro_temporal_api/jobs.py create mode 100644 pyro_temporal_api/pyro_temporal_api/main.py create mode 100644 pyro_temporal_api/requirements.txt create mode 100644 pyro_temporal_api/tests/test_jobs.py create mode 100644 pyro_temporal_api/uv.lock diff --git a/pyro_temporal_api/Dockerfile b/pyro_temporal_api/Dockerfile new file mode 100644 index 00000000..d70a5b36 --- /dev/null +++ b/pyro_temporal_api/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11.13-slim-bullseye + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/usr/src/app + +# Layer 1: git for the temporal-model dependency, curl for the healthcheck +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl git \ + || { apt-get update && apt-get install -y --no-install-recommends --fix-missing curl git; } \ + && apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Layer 2: Pip deps (onnxruntime + temporal-model-core, no torch) +COPY --from=ghcr.io/astral-sh/uv:0.6.16 /uv /bin/uv +COPY requirements.txt /tmp/requirements.txt +RUN uv pip install --no-cache --system -r /tmp/requirements.txt && \ + rm /bin/uv /tmp/requirements.txt + +# Layer 3: Source code +WORKDIR /usr/src/app +COPY pyproject.toml ./pyproject.toml +COPY pyro_temporal_api ./pyro_temporal_api + +CMD ["uvicorn", "pyro_temporal_api.main:app", "--host", "0.0.0.0", "--port", "8082"] diff --git a/pyro_temporal_api/pyproject.toml b/pyro_temporal_api/pyproject.toml new file mode 100644 index 00000000..1bdc2f5f --- /dev/null +++ b/pyro_temporal_api/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "pyro_temporal_api" +version = "0.1.0" +description = "FastAPI service validating engine alerts with the temporal smoke model (ONNX, no torch)" +authors = [{ name = "Pyronear", email = "contact@pyronear.org" }] +requires-python = ">=3.11,<3.13" +dependencies = [ + "fastapi>=0.110.0,<0.111", + "uvicorn[standard]>=0.30.0,<0.31", + "python-multipart>=0.0.9,<0.1", + "pyyaml>=6.0", + "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@449e6b049cc988bd7458ba101249680587eb0b33#subdirectory=core", +] + +[dependency-groups] +dev = [ + "pytest>=8.0,<9", + "httpx>=0.27,<1", +] + +[tool.uv] +package = false + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/pyro_temporal_api/pyro_temporal_api/__init__.py b/pyro_temporal_api/pyro_temporal_api/__init__.py new file mode 100644 index 00000000..7260a753 --- /dev/null +++ b/pyro_temporal_api/pyro_temporal_api/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. diff --git a/pyro_temporal_api/pyro_temporal_api/jobs.py b/pyro_temporal_api/pyro_temporal_api/jobs.py new file mode 100644 index 00000000..054a2894 --- /dev/null +++ b/pyro_temporal_api/pyro_temporal_api/jobs.py @@ -0,0 +1,132 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +"""Validation jobs: one submitted window of frames + boxes, scored by the temporal model in a worker thread.""" + +from __future__ import annotations + +import logging +import queue +import tempfile +import threading +import uuid +from collections import OrderedDict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from temporal_model.core import Detection, FrameDetections + +logger = logging.getLogger(__name__) + +MAX_JOBS_KEPT = 100 + + +@dataclass +class Job: + job_id: str + cam_id: str + frames: list[tuple[str, bytes]] # (frame_id, jpeg bytes), oldest first + boxes: list[list[list[float]]] # per frame: [[x1, y1, x2, y2, conf], ...] normalized xyxy + status: str = "pending" # pending | done | error + verdict: dict[str, Any] | None = None + error: str | None = None + _done: threading.Event = field(default_factory=threading.Event) + + def to_dict(self) -> dict[str, Any]: + return { + "job_id": self.job_id, + "cam_id": self.cam_id, + "status": self.status, + "verdict": self.verdict, + "error": self.error, + } + + +def _to_frame_detections(frames: list[Any], boxes: list[list[list[float]]]) -> dict[str, FrameDetections]: + """Engine boxes (normalized xyxy + conf) -> the temporal model's per-frame detections.""" + out = {} + for idx, (frame, frame_boxes) in enumerate(zip(frames, boxes, strict=True)): + out[frame.frame_id] = FrameDetections( + frame_idx=idx, + frame_id=frame.frame_id, + timestamp=frame.timestamp, + detections=[ + Detection( + class_id=0, + cx=(x1 + x2) / 2, + cy=(y1 + y2) / 2, + w=x2 - x1, + h=y2 - y1, + confidence=float(conf), + ) + for x1, y1, x2, y2, conf in frame_boxes + ], + ) + return out + + +def score(model: Any, job: Job) -> dict[str, Any]: + """Run the temporal model on a job's window; frames live on disk only for the call.""" + with tempfile.TemporaryDirectory(prefix="temporal_job_") as td: + paths = [] + for frame_id, data in job.frames: + p = Path(td) / f"{frame_id}.jpg" + p.write_bytes(data) + paths.append(p) + frames = model.load_sequence(paths) + out = model.predict(frames, frame_detections=_to_frame_detections(frames, job.boxes)) + kept = out.details["tubes"]["kept"] + probs = [t["probability"] for t in kept if t["probability"] is not None] + return { + "is_positive": bool(out.is_positive), + "probability": max(probs) if probs else None, + "n_tubes": len(kept), + } + + +class JobStore: + """FIFO of jobs scored one at a time by a daemon thread; keeps the last MAX_JOBS_KEPT results.""" + + def __init__(self, model: Any) -> None: + self._model = model + self._jobs: OrderedDict[str, Job] = OrderedDict() + self._queue: queue.Queue[Job] = queue.Queue() + self._lock = threading.Lock() + self._worker = threading.Thread(target=self._run, name="temporal-worker", daemon=True) + self._worker.start() + + def submit(self, cam_id: str, frames: list[tuple[str, bytes]], boxes: list[list[list[float]]]) -> Job: + job = Job(job_id=uuid.uuid4().hex, cam_id=cam_id, frames=frames, boxes=boxes) + with self._lock: + self._jobs[job.job_id] = job + while len(self._jobs) > MAX_JOBS_KEPT: + self._jobs.popitem(last=False) + self._queue.put(job) + return job + + def get(self, job_id: str) -> Job | None: + with self._lock: + return self._jobs.get(job_id) + + def wait(self, job_id: str, timeout: float) -> bool: + """Block until the job finishes (tests and synchronous callers).""" + job = self.get(job_id) + return job is not None and job._done.wait(timeout) + + def _run(self) -> None: + while True: + job = self._queue.get() + try: + job.verdict = score(self._model, job) + job.status = "done" + logger.info("job %s cam %s: %s", job.job_id, job.cam_id, job.verdict) + except Exception as exc: + job.status = "error" + job.error = f"{type(exc).__name__}: {exc}" + logger.exception("job %s cam %s failed", job.job_id, job.cam_id) + finally: + job.frames = [] # release the JPEGs once scored + job._done.set() diff --git a/pyro_temporal_api/pyro_temporal_api/main.py b/pyro_temporal_api/pyro_temporal_api/main.py new file mode 100644 index 00000000..36a14758 --- /dev/null +++ b/pyro_temporal_api/pyro_temporal_api/main.py @@ -0,0 +1,91 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +"""Temporal validation service. + +The engine submits a window of frames + YOLO boxes for one camera pose +(``POST /jobs``) and reads the verdict on its next round (``GET /jobs/{id}``). +Scoring runs in a worker thread so requests return immediately. The model is +the torch-free ``model_onnx.zip`` runtime of pyronear/temporal-model. +""" + +from __future__ import annotations + +import json +import logging +import os +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile +from temporal_model.core.onnx_model import OnnxTemporalModel + +from pyro_temporal_api.jobs import JobStore + +logging.basicConfig(format="%(asctime)s | %(levelname)s: %(message)s", level=logging.INFO) +logger = logging.getLogger(__name__) + +MODEL_PATH = Path(os.environ.get("TEMPORAL_MODEL_PATH", "data/model_onnx.zip")) +MAX_FRAMES = 20 + + +@asynccontextmanager +async def lifespan(app: FastAPI): + model = OnnxTemporalModel.from_package(MODEL_PATH) + app.state.jobs = JobStore(model) + app.state.model_version = model_version(MODEL_PATH) + logger.info("Loaded temporal model %s (version %s)", MODEL_PATH, app.state.model_version) + yield + + +def model_version(path: Path) -> str | None: + import zipfile + + import yaml + + with zipfile.ZipFile(path) as zf: + return yaml.safe_load(zf.read("manifest.yaml")).get("model_version") + + +app = FastAPI(title="Pyro Temporal API", lifespan=lifespan) + + +@app.get("/health") +def health(request: Request): + return {"status": "ok", "model_version": getattr(request.app.state, "model_version", None)} + + +@app.post("/jobs", status_code=202) +async def submit_job( + request: Request, + cam_id: str = Form(...), + boxes: str = Form(..., description="JSON list, one entry per frame: [[x1, y1, x2, y2, conf], ...]"), + frames: list[UploadFile] = File(..., description="JPEG frames, oldest first; filename stem = frame id"), +): + try: + parsed = json.loads(boxes) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=422, detail=f"boxes is not valid JSON: {exc}") from exc + if not isinstance(parsed, list) or not all(isinstance(fb, list) for fb in parsed): + raise HTTPException(status_code=422, detail="boxes must be a list of per-frame box lists") + if not 1 <= len(frames) <= MAX_FRAMES: + raise HTTPException(status_code=422, detail=f"expected 1 to {MAX_FRAMES} frames, got {len(frames)}") + if len(parsed) != len(frames): + raise HTTPException(status_code=422, detail=f"{len(frames)} frames but {len(parsed)} box lists") + for fb in parsed: + if any(not isinstance(b, list) or len(b) != 5 for b in fb): + raise HTTPException(status_code=422, detail="each box must be [x1, y1, x2, y2, conf]") + + payload = [(Path(f.filename or "frame").stem, await f.read()) for f in frames] + job = request.app.state.jobs.submit(cam_id, payload, parsed) + return {"job_id": job.job_id} + + +@app.get("/jobs/{job_id}") +def get_job(request: Request, job_id: str): + job = request.app.state.jobs.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="unknown job") + return job.to_dict() diff --git a/pyro_temporal_api/requirements.txt b/pyro_temporal_api/requirements.txt new file mode 100644 index 00000000..e0d091e9 --- /dev/null +++ b/pyro_temporal_api/requirements.txt @@ -0,0 +1,101 @@ +# This file was autogenerated by uv via the following command: +# uv export --no-hashes --no-emit-project --no-default-groups --no-dev --format requirements-txt -o requirements.txt +annotated-types==0.8.0 + # via pydantic +anyio==4.15.1 + # via + # httpx + # starlette + # watchfiles +certifi==2026.7.22 + # via + # httpcore + # httpx +click==8.5.0 + # via + # huggingface-hub + # uvicorn +colorama==0.4.6 ; sys_platform == 'win32' + # via + # tqdm + # uvicorn +fastapi==0.110.3 + # via pyro-temporal-api +filelock==3.32.5 + # via huggingface-hub +flatbuffers==25.12.19 + # via onnxruntime +fsspec==2026.7.0 + # via huggingface-hub +h11==0.16.0 + # via + # httpcore + # uvicorn +hf-xet==1.6.0 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' + # via huggingface-hub +httpcore==1.0.9 + # via httpx +httptools==0.8.0 + # via uvicorn +httpx==0.28.1 + # via huggingface-hub +huggingface-hub==1.30.0 + # via temporal-model-core +idna==3.19 + # via + # anyio + # httpx +numpy==1.26.4 + # via + # onnxruntime + # temporal-model-core +onnxruntime==1.29.0 + # via temporal-model-core +packaging==26.3 + # via + # huggingface-hub + # onnxruntime +pillow==12.3.0 + # via temporal-model-core +protobuf==7.36.1 + # via onnxruntime +pydantic==2.13.5 + # via + # fastapi + # temporal-model-core +pydantic-core==2.46.5 + # via pydantic +python-dotenv==1.2.3 + # via uvicorn +python-multipart==0.0.32 + # via pyro-temporal-api +pyyaml==6.0.3 + # via + # huggingface-hub + # pyro-temporal-api + # temporal-model-core + # uvicorn +starlette==0.37.2 + # via fastapi +temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@449e6b049cc988bd7458ba101249680587eb0b33#subdirectory=core + # via pyro-temporal-api +tqdm==4.70.0 + # via huggingface-hub +typing-extensions==4.16.0 + # via + # anyio + # fastapi + # huggingface-hub + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.4 + # via pydantic +uvicorn==0.30.6 + # via pyro-temporal-api +uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' + # via uvicorn +watchfiles==1.2.0 + # via uvicorn +websockets==17.1 + # via uvicorn diff --git a/pyro_temporal_api/tests/test_jobs.py b/pyro_temporal_api/tests/test_jobs.py new file mode 100644 index 00000000..d08f7e6a --- /dev/null +++ b/pyro_temporal_api/tests/test_jobs.py @@ -0,0 +1,124 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +import io +import json +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient +from PIL import Image + +from pyro_temporal_api import main +from pyro_temporal_api.jobs import JobStore, _to_frame_detections + + +class FakeModel: + """Stands in for OnnxTemporalModel: records what it saw, answers from `verdicts`.""" + + def __init__(self, positive=True, probability=0.8, fail=False): + self.positive, self.probability, self.fail = positive, probability, fail + self.calls = [] + + def load_sequence(self, paths): + return [SimpleNamespace(frame_id=p.stem, image_path=p, timestamp=None) for p in paths] + + def predict(self, frames, frame_detections): + if self.fail: + raise RuntimeError("boom") + self.calls.append((frames, frame_detections)) + kept = [{"logit": 1.0, "probability": self.probability}] if frame_detections else [] + return SimpleNamespace(is_positive=self.positive, details={"tubes": {"kept": kept}}) + + +def _jpeg() -> bytes: + buf = io.BytesIO() + Image.new("RGB", (32, 32), "gray").save(buf, format="JPEG") + return buf.getvalue() + + +@pytest.fixture +def client(): + # No `with`: the lifespan (which loads the real model) must not run in tests. + model = FakeModel() + main.app.state.jobs = JobStore(model) + main.app.state.model_version = "test" + return TestClient(main.app), model + + +def _submit(client, n_frames=3, boxes=None, cam_id="192.168.1.10_1"): + files = [("frames", (f"{cam_id}_2026-09-08T10-00-0{i}.jpg", _jpeg(), "image/jpeg")) for i in range(n_frames)] + if boxes is None: + boxes = [[[0.4, 0.4, 0.5, 0.5, 0.3]]] * n_frames + return client.post("/jobs", data={"cam_id": cam_id, "boxes": json.dumps(boxes)}, files=files) + + +def test_health(client): + c, _ = client + assert c.get("/health").json() == {"status": "ok", "model_version": "test"} + + +def test_job_lifecycle_and_conversion(client): + c, model = client + r = _submit(c, n_frames=3) + assert r.status_code == 202 + job_id = r.json()["job_id"] + assert c.app.state.jobs.wait(job_id, timeout=5) + + body = c.get(f"/jobs/{job_id}").json() + assert body["status"] == "done" + assert body["cam_id"] == "192.168.1.10_1" + assert body["verdict"] == {"is_positive": True, "probability": 0.8, "n_tubes": 1} + + frames, dets = model.calls[0] + assert [f.frame_id for f in frames] == [f"192.168.1.10_1_2026-09-08T10-00-0{i}" for i in range(3)] + d = dets[frames[0].frame_id].detections[0] + assert (d.cx, d.cy, d.w, d.h, d.confidence) == pytest.approx((0.45, 0.45, 0.1, 0.1, 0.3)) + assert dets[frames[2].frame_id].frame_idx == 2 + + +def test_model_failure_is_reported_as_error(client): + c, model = client + model.fail = True + job_id = _submit(c).json()["job_id"] + assert c.app.state.jobs.wait(job_id, timeout=5) + body = c.get(f"/jobs/{job_id}").json() + assert body["status"] == "error" + assert "RuntimeError" in body["error"] + assert body["verdict"] is None + + +def test_unknown_job_is_404(client): + c, _ = client + assert c.get("/jobs/nope").status_code == 404 + + +@pytest.mark.parametrize( + ("kwargs", "detail"), + [ + ({"n_frames": 2, "boxes": [[]]}, "box lists"), + ({"n_frames": 1, "boxes": [[[0.1, 0.1, 0.2]]]}, "each box"), + ({"n_frames": 21}, "expected 1 to 20"), + ], +) +def test_rejects_malformed_submissions(client, kwargs, detail): + c, _ = client + r = _submit(c, **kwargs) + assert r.status_code == 422 + assert detail in r.json()["detail"] + + +def test_frames_are_released_after_scoring(client): + c, _ = client + job_id = _submit(c).json()["job_id"] + assert c.app.state.jobs.wait(job_id, timeout=5) + assert c.app.state.jobs.get(job_id).frames == [] + + +def test_to_frame_detections_keeps_empty_frames(): + frames = [SimpleNamespace(frame_id="a", timestamp=None), SimpleNamespace(frame_id="b", timestamp=None)] + dets = _to_frame_detections(frames, [[], [[0, 0, 1, 1, 0.5]]]) + assert dets["a"].detections == [] + assert len(dets["b"].detections) == 1 diff --git a/pyro_temporal_api/uv.lock b/pyro_temporal_api/uv.lock new file mode 100644 index 00000000..a5e3123c --- /dev/null +++ b/pyro_temporal_api/uv.lock @@ -0,0 +1,695 @@ +version = 1 +revision = 2 +requires-python = ">=3.11, <3.13" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload_time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload_time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload_time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload_time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload_time = "2026-07-22T03:35:12.644Z" } +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" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload_time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload_time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.110.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/22/7b9ee50b0a8c48f076a111d6e4071a9d4c25623dc67689c5f3aa375f779b/fastapi-0.110.3.tar.gz", hash = "sha256:555700b0159379e94fdbfc6bb66a0f1c43f4cf7060f25239af3d84b63a656626", size = 287508, upload_time = "2024-04-30T00:38:02.576Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/d1/5958526c3bdbed74f88bf69b86506db5b25a600207f0f688473667690de6/fastapi-0.110.3-py3-none-any.whl", hash = "sha256:fd7600612f755e4050beb74001310b5a7e1796d149c2ee363124abdfa0289d32", size = 91834, upload_time = "2024-04-30T00:37:58.935Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload_time = "2026-08-31T18:56:34.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload_time = "2026-08-31T18:56:33.078Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload_time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload_time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload_time = "2026-07-28T16:34:49.538Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload_time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload_time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload_time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload_time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload_time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload_time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload_time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload_time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload_time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload_time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload_time = "2026-08-03T22:33:11.725Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload_time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload_time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload_time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload_time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload_time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload_time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload_time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload_time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload_time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload_time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload_time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload_time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload_time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload_time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload_time = "2026-05-25T22:17:15.155Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/97/2eb4abaa5b969ed385066a0496a3823b3ff467fc1082e2202955f1867d60/huggingface_hub-1.30.0.tar.gz", hash = "sha256:e6a6120bc8c8e2723d03648434ee247088cceb55ba7067e7d34d692cad5fdb57", size = 964291, upload_time = "2026-09-03T10:05:14.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/0e/3e45bbe0dd48f4e56b1d46649d342de853cd1c7e815323472ab62687f153/huggingface_hub-1.30.0-py3-none-any.whl", hash = "sha256:96ae0a8e99a234374a6fe43e989ebd21c04640b91ab2927e7e5773ba1131ca59", size = 796795, upload_time = "2026-09-03T10:05:12.21Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload_time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload_time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload_time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload_time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload_time = "2024-02-06T00:26:44.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554, upload_time = "2024-02-05T23:51:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127, upload_time = "2024-02-05T23:52:15.314Z" }, + { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994, upload_time = "2024-02-05T23:52:47.569Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005, upload_time = "2024-02-05T23:53:15.637Z" }, + { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297, upload_time = "2024-02-05T23:53:42.16Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567, upload_time = "2024-02-05T23:54:11.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812, upload_time = "2024-02-05T23:54:26.453Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913, upload_time = "2024-02-05T23:54:53.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload_time = "2024-02-05T23:55:32.801Z" }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload_time = "2024-02-05T23:55:56.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload_time = "2024-02-05T23:56:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload_time = "2024-02-05T23:56:56.054Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload_time = "2024-02-05T23:57:21.56Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload_time = "2024-02-05T23:57:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload_time = "2024-02-05T23:58:08.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload_time = "2024-02-05T23:58:36.364Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/a8/0520890321b8ff40b908cf165a93eb58fbc8f85c14db637277ea866c9544/onnxruntime-1.29.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:07c5907474dec4a2792fd7626b753dc66707808385a6d9eecf993db0066a9d0f", size = 21420890, upload_time = "2026-08-17T22:53:33.429Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/8bd3e0008ff8d386305351109a7329ea57e51a3ab57bc92340f29c4a5b5d/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:16925ef8497e2c07e4b5ae15b504079b3ab3f65e22c58efd10dde0f3caea969a", size = 20803602, upload_time = "2026-08-17T22:53:36.47Z" }, + { url = "https://files.pythonhosted.org/packages/3b/91/a66cd77f28379ede419672edda3184f1eb286db215dce1e7b976fae2d63b/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:85f8e8406c52658735fe5c7fbfd3ebaa1ed340768324f6252e4274e374580a23", size = 23113193, upload_time = "2026-08-17T22:53:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/1c/82/2da968405c42340f03de0bcdb63be09ae1004f820b2295590d48951b5cf2/onnxruntime-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:0d4f427afac434b0070fe992b540ddf20a7aff2265f760f314d91331935b6b98", size = 13999253, upload_time = "2026-08-17T22:53:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/70c9c893bf732ee66124c2d8de6a21fc9361ec62cf378f857043efcbf0eb/onnxruntime-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:4eae472cf7dc3107dec1bb53cd6d142d1964616d08aae48654cd4254b2363c4b", size = 13741410, upload_time = "2026-08-17T22:53:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/d4/80/381c1e9efed9cc32d00aa7cab0547dc84116cec906c3ffe3613686d6963a/onnxruntime-1.29.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3a3814c041251d6a77fdf513fb282056538ee826d2f1178a0df3c549d3fff6ba", size = 21430049, upload_time = "2026-08-17T22:53:48.286Z" }, + { url = "https://files.pythonhosted.org/packages/30/12/4be0e345d38fe707a701ca07e8f63c05b152a2e6285d1e43a7faf63fedd2/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d2fb19e848f7c33ed8d3182b52504aaa11c5e8da438bbb47296f85b133cbcf6b", size = 20816870, upload_time = "2026-08-17T22:53:51.169Z" }, + { url = "https://files.pythonhosted.org/packages/96/eb/e6968f5e41aac3125f2ff5708855f09cb0b70d85ed3115b625b0b58305ba/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2b80d8c7ec2cc7438e4da3760b88c24568cba72c9ace96d668800a6c79419acb", size = 23136745, upload_time = "2026-08-17T22:53:53.92Z" }, + { url = "https://files.pythonhosted.org/packages/b4/80/5b28f1f1111210fc4a336ddbc6950f468ebf9a6a265420568f4f43fa33ce/onnxruntime-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:4acf2b4948b7ede87221ca6332344b8facdc8059d6ac751a7d367d04532b02dd", size = 14001407, upload_time = "2026-08-17T22:53:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d6/6883f89ea4b044e6e8447ebfaf9bcecdf457b7d80a683635e130b25498e0/onnxruntime-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc61a79cb39afd66ab3f01fd2c23591a7f01de89c1668e1fb6315067fc279164", size = 13746981, upload_time = "2026-08-17T22:53:58.977Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload_time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload_time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload_time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload_time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload_time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload_time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload_time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload_time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload_time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload_time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload_time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload_time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload_time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload_time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload_time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload_time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload_time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload_time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload_time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload_time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload_time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload_time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload_time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload_time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload_time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload_time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.36.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/73/f66c748df06e7fe24e658eddd600d19c4b40bad836c97ce2d0ad9851fb6b/protobuf-7.36.1.tar.gz", hash = "sha256:d0f6470f0ce2b84e3feaea2d4b816378b37ba4d4aa08a274305373de93e2d524", size = 512499, upload_time = "2026-08-31T22:40:04.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/6c/3a54a58f2948b0f485df9ecdd06590f15d0a7abf46a89d50c3de709ff4ff/protobuf-7.36.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:3cf2ee25d006cee57294a1196ea43b37feb78e0dcd1e8af5c1aeddb777655aca", size = 456046, upload_time = "2026-08-31T22:39:56.865Z" }, + { url = "https://files.pythonhosted.org/packages/6e/08/9f9548793c771095245c0eeaf0c84b76b16a5ef26158043d456f551344a0/protobuf-7.36.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:43d3d37b1eb24c113b9b7d02008cac44e423f00b611b7781ae998d7623972969", size = 344226, upload_time = "2026-08-31T22:39:58.263Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/1bdbd612fa3c51e42ea6f45d05e84dc748ddcd2663e1aa1e89a00a33facd/protobuf-7.36.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:39c518c05586c016d7874ff6079ee115bcec1ea5fbb1d177fbf7867ef4c67e44", size = 357229, upload_time = "2026-08-31T22:39:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/22/df/c799fe7a05ef16ba853a59db01f3a2c5f7d0676469589ccc4874f76a2a88/protobuf-7.36.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:97198b77e369a0abd8e262b8f6c7266c55ddb796a3a12c76d7b8881188ed83aa", size = 343228, upload_time = "2026-08-31T22:40:00.179Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/d4724ec5d86d496fe4108e220618aa0837ad3423a7af7ccdd9684ccc77c8/protobuf-7.36.1-cp310-abi3-win32.whl", hash = "sha256:0b53ce95272aad50ad25d7ff03373743209822e8ba42ea7fad27d2bee1547d00", size = 443002, upload_time = "2026-08-31T22:40:01.32Z" }, + { url = "https://files.pythonhosted.org/packages/db/37/155788a0d8daded960375af604202805308169f9b859419ea0aa370946e2/protobuf-7.36.1-cp310-abi3-win_amd64.whl", hash = "sha256:51139351435d9b43d88a55eaa49fb6f737fbb478fb0cbf2cf694d1a04a9d3363", size = 456518, upload_time = "2026-08-31T22:40:02.494Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c47f91d3cab175b01fd8c4f0d80fdf8613be876cc616e66ad281a59c5ddf/protobuf-7.36.1-py3-none-any.whl", hash = "sha256:7d951e46b3f963d6c264c367c437921de9d5aedd9c3f9612b9077736b4e3ad5c", size = 179813, upload_time = "2026-08-31T22:40:03.54Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload_time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload_time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload_time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload_time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload_time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload_time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload_time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload_time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload_time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload_time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload_time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload_time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload_time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload_time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload_time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload_time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload_time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload_time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload_time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload_time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload_time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload_time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload_time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload_time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload_time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload_time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload_time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload_time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload_time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload_time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload_time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload_time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload_time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload_time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload_time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload_time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload_time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload_time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload_time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload_time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload_time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload_time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload_time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload_time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload_time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload_time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload_time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload_time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload_time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload_time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload_time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyro-temporal-api" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "temporal-model-core", extra = ["onnx"] }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.110.0,<0.111" }, + { name = "python-multipart", specifier = ">=0.0.9,<0.1" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=449e6b049cc988bd7458ba101249680587eb0b33" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.31" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27,<1" }, + { name = "pytest", specifier = ">=8.0,<9" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload_time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload_time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload_time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload_time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload_time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload_time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload_time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload_time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload_time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload_time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload_time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload_time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload_time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload_time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload_time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload_time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload_time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload_time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload_time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload_time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload_time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload_time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload_time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload_time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload_time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload_time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "starlette" +version = "0.37.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/b5/6bceb93ff20bd7ca36e6f7c540581abb18f53130fabb30ba526e26fd819b/starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823", size = 2843736, upload_time = "2024-03-05T16:16:54.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/18/31fa32ed6c68ba66220204ef0be798c349d0a20c1901f9d4a794e08c76d8/starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee", size = 71908, upload_time = "2024-03-05T16:16:50.957Z" }, +] + +[[package]] +name = "temporal-model-core" +version = "0.1.0" +source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=449e6b049cc988bd7458ba101249680587eb0b33#449e6b049cc988bd7458ba101249680587eb0b33" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +onnx = [ + { name = "onnxruntime" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload_time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload_time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload_time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload_time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload_time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload_time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.30.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/01/5e637e7aa9dd031be5376b9fb749ec20b86f5a5b6a49b87fabd374d5fa9f/uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788", size = 42825, upload_time = "2024-08-13T09:27:35.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/8e/cdc7d6263db313030e4c257dd5ba3909ebc4e4fb53ad62d5f09b1a2f5458/uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5", size = 62835, upload_time = "2024-08-13T09:27:33.536Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload_time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload_time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload_time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload_time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload_time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload_time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload_time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload_time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload_time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload_time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload_time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload_time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload_time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload_time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload_time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload_time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload_time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload_time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload_time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload_time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload_time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload_time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload_time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload_time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload_time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload_time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload_time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload_time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload_time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload_time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload_time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload_time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload_time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload_time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload_time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload_time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload_time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload_time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload_time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload_time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload_time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload_time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload_time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload_time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload_time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload_time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload_time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ad/66a74d42fb537bd44056483eae6cbb7ebb10b742c300a0bf8cee427556d4/websockets-17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:88b882764ef65147a7a5ae13168dedbe225a04e2ff4858fe543f2c402f093e9c", size = 216984, upload_time = "2026-08-26T14:55:20.747Z" }, + { url = "https://files.pythonhosted.org/packages/70/1b/344ab22cea729e872f759b926441f7b822ab6cd106db527736afc066927f/websockets-17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:98a5b2589a56a4b4f098b0a958099a4356ab904a7844f1da3841efca469af7e9", size = 214667, upload_time = "2026-08-26T14:55:22.298Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/bace574b6ae80e1a8d6935b8c5f03fb67236233ec572e976fe826ff719cf/websockets-17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:020e271205f8ab3406d7a59cd00de6dec722315924411c421bd00642f18bad86", size = 214944, upload_time = "2026-08-26T14:55:23.618Z" }, + { url = "https://files.pythonhosted.org/packages/ee/87/08e35ca4a0ffafb500a16ff461bf9561ad2b755362adb5d077d4dba9affc/websockets-17.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:65be6bda2b537fefa4b3a5ccd6ab386533ce39dd8fe62433ec90901fdc81752d", size = 224004, upload_time = "2026-08-26T14:55:24.748Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/00ae2e147eaa086fe8bdddd36f57216ce72b9a9dfc0b17c717005ebdacaf/websockets-17.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c84bdef916556cbe1d5a43b423398be4dd3cba6522b463e53d848578b920695", size = 224278, upload_time = "2026-08-26T14:55:26.016Z" }, + { url = "https://files.pythonhosted.org/packages/98/e2/7aeb4e00defa68826f449392922a382ce7fdf542fe52190558dc1714e284/websockets-17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47a62d6045c6eaa0d8f97bc2fb68b8cf90077a0cbfd4e83d6f2d2145611ee134", size = 225511, upload_time = "2026-08-26T14:55:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/655be3d93c3edbe1a51606073b5454ea6b1b32d87aa26253a6df952417b7/websockets-17.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34879e19bb0a3c44f9317679435aea5327fac993933a704cbf353bf1234b10c7", size = 228802, upload_time = "2026-08-26T14:55:28.431Z" }, + { url = "https://files.pythonhosted.org/packages/e4/33/98549a2afa9d68fe1b5a8e0a61cd461a43ea1ab7209bce675eea67c79190/websockets-17.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d72879819f5145a342d0030c418702496c65a4b913ef81f5ae944dd91dd50f6", size = 226075, upload_time = "2026-08-26T14:55:29.695Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6b/cbc27e014d6c292b9b2709cfd32781a2b61eb30cd4c9130e7c57e41a204a/websockets-17.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f25e099fdfe3b09f953d84698f729a1f7d1e99101b2787d7a28ed77b323750", size = 224846, upload_time = "2026-08-26T14:55:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/e57925f7a423d90f24559e85bac21a7f0b44c0cf4a5c0babc0759ca54bab/websockets-17.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:469355ab1af100b9380f1afb09985019f4a4b94fa1dd0e9396db4361626d7ab8", size = 222136, upload_time = "2026-08-26T14:55:32.376Z" }, + { url = "https://files.pythonhosted.org/packages/32/07/b9de0400addb542ba7c819022abc3afa46cd7e518068881bceadac69d995/websockets-17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00679b7468b4c2b12b0757118174e8eabac56bb2f579a928a104d9554a56e098", size = 225000, upload_time = "2026-08-26T14:55:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bb/af2828a1d7f2beb792af6ba56d7b02d56262070266b95d2af9ef391fbfb0/websockets-17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a9fe648abd1d9b89aebfa30407bfdd08a0271ec5dc7d44a4c6ccd1ce22cf562a", size = 223592, upload_time = "2026-08-26T14:55:34.636Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/7379f254730c1dc7d8e4dd8d686868d8f7be55bdc94c6d3a44538840f639/websockets-17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f47aafd92aa28b941180e6da8a42b0f711851b14b81a5b6bb28dbbb1fa35152c", size = 224360, upload_time = "2026-08-26T14:55:35.777Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/fcd91320dd71dda7046df9bc60f60c70ee15c052dee21e31ed6221dc8b5d/websockets-17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c89406fa3dcd4aa8662c6406cc5c0de1790e9614d2c3aaf03ca53a8a8ccf3405", size = 225404, upload_time = "2026-08-26T14:55:36.85Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b2/655a4f939388079f80f1b3f8a1b9d40783e70a376e7423988cc9a590a09f/websockets-17.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b3b451fd2723ad3191a209afe6f3f4bc86c83e9a85bdc255353b91803ee6aa66", size = 222982, upload_time = "2026-08-26T14:55:38.006Z" }, + { url = "https://files.pythonhosted.org/packages/87/75/37c84c4371c6aa668910d7841036c4397ea10554c07030603ccd5e44b02a/websockets-17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:054c28db2dcec0e857e3b705d8c28012613e555b38c765d6a4f75340a4fc06a0", size = 224017, upload_time = "2026-08-26T14:55:39.39Z" }, + { url = "https://files.pythonhosted.org/packages/5e/20/8a9a94323bfcfe03bde3f9d98926bea4855856359702fe3ca0d07051ef5d/websockets-17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f8e822efd54137d8cc8310eb64635ab827a4a6c72ff08691f38aa624776d8ecb", size = 224252, upload_time = "2026-08-26T14:55:40.498Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dd/aa66e6500188cd40306abeb92c9a738ca6dd7029d8d8532c538055ab5daf/websockets-17.1-cp311-cp311-win32.whl", hash = "sha256:dcb8d5f7edef7a399d322cf28d4c4e6f98dab64d301c8f50581a1080e5198142", size = 217484, upload_time = "2026-08-26T14:55:41.763Z" }, + { url = "https://files.pythonhosted.org/packages/01/a2/cdf3b551f0b9177023afd3a45d3b431a0d4064951008c4321a8b42ac2288/websockets-17.1-cp311-cp311-win_amd64.whl", hash = "sha256:b1bc819c6db90e8f91a38250a1ab4c058261871aa52d2fe36382eddedf146dee", size = 217779, upload_time = "2026-08-26T14:55:42.942Z" }, + { url = "https://files.pythonhosted.org/packages/e0/13/51253dbed7d16a4bb87b05110ad3bf12165f410e915f6da1edd4186d8dc1/websockets-17.1-cp311-cp311-win_arm64.whl", hash = "sha256:edadce7a22052056fd4384543019856b34850363c9d387929f677ae01d79709c", size = 217710, upload_time = "2026-08-26T14:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload_time = "2026-08-26T14:55:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload_time = "2026-08-26T14:55:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload_time = "2026-08-26T14:55:47.885Z" }, + { url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload_time = "2026-08-26T14:55:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload_time = "2026-08-26T14:55:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload_time = "2026-08-26T14:55:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload_time = "2026-08-26T14:55:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload_time = "2026-08-26T14:55:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload_time = "2026-08-26T14:55:55.943Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload_time = "2026-08-26T14:55:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload_time = "2026-08-26T14:55:58.375Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload_time = "2026-08-26T14:55:59.664Z" }, + { url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload_time = "2026-08-26T14:56:00.873Z" }, + { url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload_time = "2026-08-26T14:56:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload_time = "2026-08-26T14:56:03.092Z" }, + { url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload_time = "2026-08-26T14:56:04.321Z" }, + { url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload_time = "2026-08-26T14:56:05.521Z" }, + { url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload_time = "2026-08-26T14:56:06.715Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload_time = "2026-08-26T14:56:08.055Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload_time = "2026-08-26T14:56:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e4/af4abbcf07eac6a725ec6f865611526b2b0c23d482723de551bec667880d/websockets-17.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:10ecb38ffc05e1841b619d99c725307a223ef9ad58e7b1ed33311d472dc43918", size = 214602, upload_time = "2026-08-26T14:58:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/4d/fe/819fba7ba35f92b639333da7355041c07dd50048f9c76fba0b8e292a6483/websockets-17.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17aa424ab61620aad21b36b2240efc87b500cc496e7d0e999a5c2ae99395e886", size = 214874, upload_time = "2026-08-26T14:58:26.689Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/d370ab794f47fbeea648d17ad08caf0bb50131d6c04b7ad83e6af63c405a/websockets-17.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:764cf7bfa149365f32b7a0fd9fed32debdac29dd06295d5635cde1745b446cd8", size = 215821, upload_time = "2026-08-26T17:25:23.616Z" }, + { url = "https://files.pythonhosted.org/packages/9b/6b/251b00fe634e2a9c2cb5d6390e0e97cec55e3d18dd09b4b976620eed5d7b/websockets-17.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d1b108bd8f5f6a8b90801f6db3b3858d5deca889acfdb8ac497bbb24e4b0edf", size = 215714, upload_time = "2026-08-26T17:25:26.295Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/37fe0c96c206b4208a072c3a74add6a72af4b8228be3f5435163c5a6d099/websockets-17.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a62d8c424383c9dc769ff3672018df822603117e32686e567d452ed035b6fb2e", size = 216608, upload_time = "2026-08-26T17:25:28.134Z" }, + { url = "https://files.pythonhosted.org/packages/be/7e/75a0a491b512412e08333b9f8412757af6186fe1c598186261002de1a793/websockets-17.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8196d217eeca52b9235ee1f8a684a09885a5f953d5a31e80ef915bf2c5c94f9d", size = 217870, upload_time = "2026-08-26T17:25:29.745Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload_time = "2026-08-26T17:25:31.397Z" }, +] From 1b88764180ba58970b558292669d1f8c33da76d9 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:16:30 +0200 Subject: [PATCH 02/12] feat(engine): hold alerts until the temporal service confirms them With TEMPORAL_API_URL set, the engine keeps the last 10 inference frames and boxes per camera pose, submits them when an alert becomes ongoing and reads the verdict on the next round. Negative verdicts resubmit the longer window; service errors fail open. --- pyroengine/engine.py | 71 ++++++++++++++++- pyroengine/temporal_client.py | 42 +++++++++++ src/run.py | 3 + tests/test_engine_temporal.py | 138 ++++++++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 pyroengine/temporal_client.py create mode 100644 tests/test_engine_temporal.py diff --git a/pyroengine/engine.py b/pyroengine/engine.py index c4440138..853bb99e 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -10,7 +10,7 @@ import time from collections import deque from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Never, Optional, Tuple @@ -23,6 +23,8 @@ from requests.exceptions import RequestException from requests.models import Response +from pyroengine.temporal_client import TemporalClient + __all__ = ["ContextCrop", "Engine"] # Client errors worth retrying later; every other 4xx is a permanent rejection of the payload. @@ -140,9 +142,16 @@ def __init__( save_detections_frames: Optional[bool] = False, send_last_image_period: int = 3600, # 1H last_bbox_mask_fetch_period: int = 3600, # 1H + temporal_api_url: Optional[str] = None, + temporal_window: int = 10, **kwargs: Any, # noqa: ANN401 ) -> None: cam_ids = list(cam_creds.keys()) if isinstance(cam_creds, dict) else None + # Temporal validation: when set, an ongoing alert is only staged once the temporal + # service has confirmed the last `temporal_window` frames of that camera pose. + # Set before super().__init__, which already builds the per-camera states. + self.temporal = TemporalClient(temporal_api_url) if temporal_api_url else None + self.temporal_window = temporal_window super().__init__( model_path=model_path, conf_thresh=conf_thresh, @@ -186,6 +195,7 @@ def __init__( state["last_bbox_mask_fetch"] = None state["last_pose_image_sent"] = init_now state["event_crop_boxes"] = [] + self._init_temporal_state(state) # Occlusion masks: cam_id -> dict of bboxes (keyed by mask id) self.occlusion_masks: Dict[str, Dict[Any, Any]] = {} @@ -202,8 +212,14 @@ def _new_state(self) -> Dict[str, Any]: state["last_bbox_mask_fetch"] = None state["last_pose_image_sent"] = datetime.now() state["event_crop_boxes"] = [] + self._init_temporal_state(state) return state + def _init_temporal_state(self, state: Dict[str, Any]) -> None: + state["temporal_frames"] = deque(maxlen=self.temporal_window) + state["temporal_job"] = None + state["temporal_validated"] = False + def _end_event(self, cam_key: str) -> None: """Reset per-event staging state when an alert ends. @@ -214,11 +230,59 @@ def _end_event(self, cam_key: str) -> None: """ state = self._states[cam_key] state["event_crop_boxes"] = [] + # The temporal frame window survives events; only the verdict of this event is dropped. + state["temporal_job"] = None + state["temporal_validated"] = False window = state["last_predictions"] for i, entry in enumerate(window): if entry[4]: # is_staged: belongs to the event that just ended window[i] = (entry[0], entry[1], [], entry[3], True, entry[5]) + def _temporal_gate(self, cam_key: str, cam_id: str) -> bool: + """Return True when this event may be staged: temporal verdict positive, or service unavailable. + + Round N submits the frame window; round N+1 reads the verdict. A negative verdict resubmits + the window (now one frame longer) so the event can still be confirmed later. A transport + error or a failed job fails open: the alert goes out as without temporal validation. + """ + state = self._states[cam_key] + if state["temporal_validated"] or self.temporal is None: + return True + try: + job_id = state["temporal_job"] + if job_id is not None: + result = self.temporal.result(job_id) + if result["status"] == "pending": + logger.info(f"Camera '{cam_id}' - temporal verdict pending, alert held") + return False + state["temporal_job"] = None + if result["status"] == "done": + verdict = result["verdict"] + prob = verdict.get("probability") + prob_str = f"{prob:.2%}" if prob is not None else "n/a" + if verdict["is_positive"]: + logger.info(f"Camera '{cam_id}' - temporal model confirmed the alert (probability: {prob_str})") + state["temporal_validated"] = True + return True + logger.info( + f"Camera '{cam_id}' - temporal model rejected the alert " + f"(probability: {prob_str}, tubes: {verdict.get('n_tubes')}), resubmitting" + ) + else: + logger.error( + f"Camera '{cam_id}' - temporal job failed ({result.get('error')}), alert sent unvalidated" + ) + state["temporal_validated"] = True + return True + window = list(state["temporal_frames"]) + state["temporal_job"] = self.temporal.submit(cam_id, window) + logger.info(f"Camera '{cam_id}' - temporal validation submitted ({len(window)} frames), alert held") + return False + except (RequestException, KeyError, ValueError) as e: + logger.error(f"Camera '{cam_id}' - temporal service unavailable ({e}), alert sent unvalidated") + state["temporal_validated"] = True + return True + def heartbeat(self, cam_id: str) -> Response: """Updates last ping of device""" ip = cam_id.split("_")[0] @@ -341,6 +405,9 @@ def predict( extra_boxes = state["event_crop_boxes"] if state["ongoing"] else None context_crop = self._build_context_crop(original_frame, preds, extra_boxes) conf = self._update_states(context_crop, preds, cam_key, encoded_bytes=encoded_bytes) + if self.temporal is not None and encoded_bytes is not None: + frame_id = f"{cam_key}_{datetime.now(timezone.utc).strftime('%Y-%m-%dT%H-%M-%S')}" + state["temporal_frames"].append((frame_id, encoded_bytes, np.round(preds[:, :5], 4).tolist())) if not self._states[cam_key]["ongoing"]: self._end_event(cam_key) @@ -354,6 +421,8 @@ def predict( # Alert (use ongoing so hysteresis-relaxed threshold keeps staging frames during a dip) if self._states[cam_key]["ongoing"] and len(self.api_client) > 0 and isinstance(cam_id, str): + if self.temporal is not None and not self._temporal_gate(cam_key, cam_id): + return float(conf) state = self._states[cam_key] # Collect every bbox the predictor emitted across the window; treat these as # tracked locations and backfill missing per-frame bboxes from raw preds with conf=0. diff --git a/pyroengine/temporal_client.py b/pyroengine/temporal_client.py new file mode 100644 index 00000000..87e682d9 --- /dev/null +++ b/pyroengine/temporal_client.py @@ -0,0 +1,42 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +"""Client for the temporal validation service (pyro_temporal_api).""" + +import json +from typing import Any, Dict, List, Sequence, Tuple + +import requests + +__all__ = ["TemporalClient"] + +# (frame_id, jpeg bytes, boxes as [[x1, y1, x2, y2, conf], ...] normalized xyxy) +WindowFrame = Tuple[str, bytes, List[List[float]]] + + +class TemporalClient: + """Submit a window of frames for one camera pose, then poll the verdict on a later round.""" + + def __init__(self, url: str, timeout: float = 10.0) -> None: + self.url = url.rstrip("/") + self.timeout = timeout + + def submit(self, cam_id: str, window: Sequence[WindowFrame]) -> str: + files = [("frames", (f"{frame_id}.jpg", data, "image/jpeg")) for frame_id, data, _ in window] + boxes = [frame_boxes for _, _, frame_boxes in window] + response = requests.post( + f"{self.url}/jobs", + data={"cam_id": cam_id, "boxes": json.dumps(boxes)}, + files=files, + timeout=self.timeout, + ) + response.raise_for_status() + return str(response.json()["job_id"]) + + def result(self, job_id: str) -> Dict[str, Any]: + """Return the job dict: ``status`` is ``pending``, ``done`` (with ``verdict``) or ``error``.""" + response = requests.get(f"{self.url}/jobs/{job_id}", timeout=self.timeout) + response.raise_for_status() + return dict(response.json()) diff --git a/src/run.py b/src/run.py index 55cabdf4..c70b01ba 100644 --- a/src/run.py +++ b/src/run.py @@ -34,6 +34,8 @@ def main(args): cam_pwd = os.environ.get("CAM_PWD") assert isinstance(cam_user, str) assert isinstance(cam_pwd, str) + # Optional temporal validation service (pyro_temporal_api); unset = alerts sent as before + temporal_api_url = os.environ.get("TEMPORAL_API_URL") or None # Loading camera creds with pathlib.Path(args.creds).open("rb") as json_file: @@ -81,6 +83,7 @@ def main(args): day_time_strategy=args.day_time_strategy, save_captured_frames=args.save_captured_frames, save_detections_frames=args.save_detections_frames, + temporal_api_url=temporal_api_url, ) sys_controller = SystemController(engine, camera_data, args.pyro_camera_api_url) diff --git a/tests/test_engine_temporal.py b/tests/test_engine_temporal.py new file mode 100644 index 00000000..7fdd53a4 --- /dev/null +++ b/tests/test_engine_temporal.py @@ -0,0 +1,138 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +from unittest.mock import MagicMock + +import numpy as np +import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError + +from pyroengine.engine import Engine + +CAM = "192.168.1.10_1" +# fake_pred is (cx, cy, w, h, conf) in model pixel space (imgsz 1024); +# post_process turns it into normalized xyxy [0.4, 0.4, 0.5, 0.5, 0.6] +PRED = np.array([[460.8, 460.8, 102.4, 102.4, 0.6]]) + + +class FakeTemporal: + """Scripted temporal service: `results` are returned in order by result(); submit() records windows.""" + + def __init__(self, results=()) -> None: + self.results = list(results) + self.submitted = [] + self.n = 0 + + def submit(self, cam_id, window): + self.n += 1 + self.submitted.append((cam_id, list(window))) + return f"job{self.n}" + + def result(self, _job_id): + return self.results.pop(0) + + +def _engine(tmp_path, temporal, **kwargs): + engine = Engine( + cache_folder=str(tmp_path), + conf_thresh=0.1, + nb_consecutive_frames=2, + temporal_api_url="http://localhost:8082", + cam_creds={CAM: ("token", 7)}, + **kwargs, + ) + engine.temporal = temporal + engine.api_client = {"192.168.1.10": MagicMock()} # no api_url: a real Client would call the API + return engine + + +def _fire(engine, image, n=1): + for _ in range(n): + engine.predict(image, CAM, fake_pred=PRED.T.copy()) + return engine._states[CAM] + + +def test_window_collects_frames_and_boxes(tmp_path, mock_wildfire_image): + engine = _engine(tmp_path, FakeTemporal([{"status": "pending"}] * 3), temporal_window=3) + state = _fire(engine, mock_wildfire_image, n=4) + window = list(state["temporal_frames"]) + assert len(window) == 3 # bounded ring buffer + frame_id, jpeg, boxes = window[-1] + assert frame_id.startswith(f"{CAM}_20") + assert jpeg[:2] == b"\xff\xd8" # JPEG magic + assert boxes == [[0.4, 0.4, 0.5, 0.5, 0.6]] + + +def test_alert_held_until_positive_verdict(tmp_path, mock_wildfire_image): + done = lambda positive: {"status": "done", "verdict": {"is_positive": positive, "probability": 0.5, "n_tubes": 1}} + temporal = FakeTemporal([{"status": "pending"}, done(False), done(True)]) + engine = _engine(tmp_path, temporal) + + state = _fire(engine, mock_wildfire_image) # ongoing on the first frame -> submit + assert state["ongoing"] + assert len(temporal.submitted) == 1 + assert state["temporal_job"] == "job1" + assert len(engine._alerts) == 0 + + _fire(engine, mock_wildfire_image) # pending -> held + assert len(engine._alerts) == 0 + assert state["temporal_job"] == "job1" + + _fire(engine, mock_wildfire_image) # negative -> resubmitted with a longer window + assert len(engine._alerts) == 0 + assert state["temporal_job"] == "job2" + assert len(temporal.submitted[1][1]) == 3 + + _fire(engine, mock_wildfire_image) # positive -> validated, window staged + assert state["temporal_validated"] is True + assert state["temporal_job"] is None + assert len(engine._alerts) > 0 + assert not temporal.results # every scripted verdict was consumed + + n_staged = len(engine._alerts) + _fire(engine, mock_wildfire_image) # validated event: no more calls, keeps staging + assert len(temporal.submitted) == 2 + assert len(engine._alerts) > n_staged + + +@pytest.mark.parametrize( + "temporal", + [ + FakeTemporal([{"status": "error", "verdict": None, "error": "RuntimeError: boom"}]), + MagicMock(submit=MagicMock(side_effect=RequestsConnectionError("down"))), + ], + ids=["job_error", "service_down"], +) +def test_service_failure_fails_open(tmp_path, mock_wildfire_image, temporal): + engine = _engine(tmp_path, temporal) + state = _fire(engine, mock_wildfire_image) + if isinstance(temporal, FakeTemporal): + assert len(engine._alerts) == 0 # round N: submitted, held + _fire(engine, mock_wildfire_image) # round N+1: job error + assert state["temporal_validated"] is True + assert len(engine._alerts) > 0 + + +def test_end_of_event_resets_verdict_but_keeps_window(tmp_path, mock_wildfire_image, mock_forest_image): + temporal = FakeTemporal([{"status": "done", "verdict": {"is_positive": True, "probability": 0.9, "n_tubes": 1}}]) + engine = _engine(tmp_path, temporal) + state = _fire(engine, mock_wildfire_image, n=3) + assert state["temporal_validated"] is True + n_frames = len(state["temporal_frames"]) + + for _ in range(3): + engine.predict(mock_forest_image, CAM, fake_pred=np.empty((5, 0))) + assert state["ongoing"] is False + assert state["temporal_validated"] is False + assert state["temporal_job"] is None + assert len(state["temporal_frames"]) >= n_frames + + +def test_disabled_without_url(tmp_path, mock_wildfire_image): + engine = Engine(cache_folder=str(tmp_path), conf_thresh=0.1, nb_consecutive_frames=2) + assert engine.temporal is None + state = _fire(engine, mock_wildfire_image, n=2) + assert state["ongoing"] + assert len(state["temporal_frames"]) == 0 From 7fa1d3646e8833c442c6c94d43aa075a231a83e2 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:16:30 +0200 Subject: [PATCH 03/12] chore: wire pyro-temporal-api into compose, Makefile, lint config and docs --- .env.example | 1 + .github/workflows/tests.yml | 2 +- CLAUDE.md | 11 ++++++++--- Makefile | 8 +++++++- README.md | 8 +++++++- docker-compose.yml | 24 ++++++++++++++++++++++++ pyproject.toml | 5 +++-- 7 files changed, 51 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index a40d4113..d4eadcd1 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,4 @@ PYRO_ENGINE_VERSION=latest # Stuck-PTZ detector (auto-reboots a PTZ camera that stops rotating during patrol). # Enabled by default. Set to "false" to disable. ENABLE_STUCK_DETECTOR=true +TEMPORAL_API_URL=http://localhost:8082 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 14928a6c..4c71c668 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -112,5 +112,5 @@ jobs: license: 'Apache-2.0' owner: 'Pyronear' starting-year: 2022 - folders: 'pyroengine,docs,scripts,.github,src,pyro_camera_api,pyro-predictor/pyro_predictor' + folders: 'pyroengine,docs,scripts,.github,src,pyro_camera_api,pyro_temporal_api,pyro-predictor/pyro_predictor' ignore-files: 'version.py,__init__.py' diff --git a/CLAUDE.md b/CLAUDE.md index ba9f8a5d..7cbde8b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,12 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -PyroEngine is a wildfire detection system for edge devices (Raspberry Pi, etc.). It has two main packages: +PyroEngine is a wildfire detection system for edge devices (Raspberry Pi, etc.). It has three packages: - **`pyroengine/`** — Core detection engine: runs YOLO model inference on camera images, manages alert states, communicates with the PyroNear API. - **`pyro_camera_api/`** — FastAPI service: unified REST interface for controlling heterogeneous cameras (Reolink, Linovision/Hikvision, RTSP, HTTP URL, generic REST/JSON snapshot API). +- **`pyro_temporal_api/`** — Optional FastAPI service: validates ongoing alerts with the temporal smoke model (`pyronear/temporal-model`, ONNX runtime, no torch) before the engine sends them to the PyroNear API. -These two services run as separate Docker containers and communicate over localhost (host network mode). The engine calls the camera API to capture frames and manage PTZ patrols. +These services run as separate Docker containers and communicate over localhost (host network mode). The engine calls the camera API to capture frames and manage PTZ patrols. ## Common Commands @@ -81,6 +82,10 @@ pytest tests/test_engine.py -v For each `cam_id`, the engine periodically fetches a JSON file at `{bbox_mask_url}_{pose_id}.json` from a remote URL to get a dict of bounding boxes marking permanently occluded regions. Predictions with IoU > 0.1 against any occlusion box are dropped before confidence scoring. +### Temporal validation (optional) + +When `TEMPORAL_API_URL` is set, `Engine` keeps the last `temporal_window` (10) inference JPEGs and their YOLO boxes per `cam_id`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors fail open (alert sent unvalidated). The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. + ### Stream-awareness `SystemController.inference_loop` calls `_any_stream_active()` before and during every camera loop. If an active RTSP/SRT pipeline is detected (via `/stream/status`), the entire inference pass is skipped to avoid interfering with live streaming. @@ -91,7 +96,7 @@ Day is determined by IR-channel analysis (`is_day_time(strategy="ir")`): if `max ### Environment Variables (`.env`) -Key vars used at runtime: `LAT`, `LON`, `API_URL`, `API_TOKEN`, `CAM_USER`, `CAM_PWD`, `MEDIAMTX_SERVER_IP`, `ROUTER_IP`, `ROUTER_USER`, `ROUTER_PASSWORD`, `ENABLE_ROUTER_REBOOT`. +Key vars used at runtime: `LAT`, `LON`, `API_URL`, `API_TOKEN`, `CAM_USER`, `CAM_PWD`, `MEDIAMTX_SERVER_IP`, `ROUTER_IP`, `ROUTER_USER`, `ROUTER_PASSWORD`, `ENABLE_ROUTER_REBOOT`, `TEMPORAL_API_URL` (e.g. `http://localhost:8082`, unset = no temporal validation), `TEMPORAL_MODEL_PATH`. ### Legacy direct-camera module diff --git a/Makefile b/Makefile index 201f64ef..95251c12 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,12 @@ build-api: docker build -f pyro_camera_api/Dockerfile pyro_camera_api -t pyronear/pyro-camera-api:latest # Build the engine Docker image +build-temporal: + cd pyro_temporal_api && \ + uv lock && \ + uv export --no-hashes --no-emit-project --no-default-groups --no-dev --format requirements-txt -o requirements.txt + docker build -f pyro_temporal_api/Dockerfile pyro_temporal_api -t pyronear/pyro-temporal-api:latest + build-app: docker build . -t pyronear/pyro-engine:latest @@ -53,7 +59,7 @@ run: docker compose up -d # Build images locally and run the stack -run_local: build-api build-app +run_local: build-api build-temporal build-app docker compose up -d # Get log from engine wrapper diff --git a/README.md b/README.md index 6658c09a..2b9fa039 100644 --- a/README.md +++ b/README.md @@ -113,9 +113,15 @@ CAM_USER=my_dummy_login CAM_PWD=my_dummy_pwd MEDIAMTX_SERVER_IP=1.2.3.4 PYRO_ENGINE_VERSION=latest +TEMPORAL_API_URL=http://localhost:8082 ``` -`PYRO_ENGINE_VERSION` controls which Docker image tag is pulled for both services (defaults to `latest` if unset). +`PYRO_ENGINE_VERSION` controls which Docker image tag is pulled for the services (defaults to `latest` if unset). + +`TEMPORAL_API_URL` enables the optional temporal validation: while an alert is ongoing, the engine sends the last +frames and boxes of that camera pose to `pyro_temporal_api` and only uploads the alert once the temporal smoke +model confirms it. Leave it unset to send alerts as before. The service needs `data/model_onnx.zip`, exported from +a [temporal-model](https://github.com/pyronear/temporal-model) release with `temporal-export-onnx`. ### Data directory diff --git a/docker-compose.yml b/docker-compose.yml index 28bd1265..1e658d57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,29 @@ services: max-file: "5" + pyro-temporal-api: + build: + context: ./pyro_temporal_api + image: pyronear/pyro-temporal-api:${PYRO_ENGINE_VERSION:-latest} + container_name: pyro-temporal-api + environment: + TEMPORAL_MODEL_PATH: ${TEMPORAL_MODEL_PATH:-data/model_onnx.zip} + volumes: + - ./data:/usr/src/app/data + restart: always + network_mode: host + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8082/health || exit 1"] + interval: 30s + retries: 3 + start_period: 30s + timeout: 5s + logging: + driver: json-file + options: + max-size: "100m" + max-file: "5" + engine: build: context: ./engine @@ -39,6 +62,7 @@ services: API_URL: ${API_URL} CAM_USER: ${CAM_USER} CAM_PWD: ${CAM_PWD} + TEMPORAL_API_URL: ${TEMPORAL_API_URL:-} volumes: - ./data:/usr/src/app/data command: > diff --git a/pyproject.toml b/pyproject.toml index c9737a69..7795dd98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ source = ["pyroengine"] [tool.mypy] python_version = "3.11" -files = "pyroengine/,pyro_camera_api/,pyro-predictor/pyro_predictor/" +files = "pyroengine/,pyro_camera_api/,pyro_temporal_api/,pyro-predictor/pyro_predictor/" show_error_codes = true pretty = true warn_unused_ignores = true @@ -135,7 +135,7 @@ exclude = [".git", "pyro-predictor/**", "docs/**"] docstring-quotes = "double" [tool.ruff.lint.isort] -known-first-party = ["pyroengine", "pyro_camera_api"] +known-first-party = ["pyroengine", "pyro_camera_api", "pyro_temporal_api"] known-third-party = ["pillow", "tqdm", "onnxruntime", "huggingface_hub"] [tool.ruff.lint.per-file-ignores] @@ -145,6 +145,7 @@ known-third-party = ["pillow", "tqdm", "onnxruntime", "huggingface_hub"] ".github/**.py" = ["D", "T201", "ANN", "S", "PYI024"] "tests/**.py" = ["D103", "CPY001", "S101", "T201", "ANN001", "ANN201", "ANN202", "ARG001", "S113"] "pyro_camera_api/**.py" = ["D", "T201", "S101", "ANN", "BLE001", "S113", "S501", "S404", "S603", "S405", "S314", "E402", "RUF029"] +"pyro_temporal_api/**.py" = ["D", "T201", "S101", "ANN", "BLE001", "S113", "S501", "E402", "RUF029"] "pyroengine/core.py" = ["BLE001"] "pyroengine/sensors.py" = ["S113", "S501", "ANN"] "watchdog/**.py" = ["CPY001", "S108", "S310", "S404", "S603", "S607", "BLE001", "LOG015"] From e0dd7de938b44cbd8c3e3f3c8c1300444735c148 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:17:36 +0200 Subject: [PATCH 04/12] fix(temporal): build on bookworm, bullseye-security release file is expired --- pyro_temporal_api/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyro_temporal_api/Dockerfile b/pyro_temporal_api/Dockerfile index d70a5b36..4bf7e524 100644 --- a/pyro_temporal_api/Dockerfile +++ b/pyro_temporal_api/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.13-slim-bullseye +FROM python:3.11.13-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ From 334f82e873f201deec627c29de22dbf708ac3ec8 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:17:55 +0200 Subject: [PATCH 05/12] chore: pull and tail the temporal API image in Makefile targets --- Makefile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Makefile b/Makefile index 95251c12..048a1ddd 100644 --- a/Makefile +++ b/Makefile @@ -56,6 +56,7 @@ build-optional-lib: run: docker pull pyronear/pyro-engine:latest docker pull pyronear/pyro-camera-api:latest + docker pull pyronear/pyro-temporal-api:latest docker compose up -d # Build images locally and run the stack @@ -66,6 +67,10 @@ run_local: build-api build-temporal build-app log: docker logs -f --tail 50 engine +# Get log from temporal API wrapper +log-temporal: + docker logs -f --tail 50 pyro-temporal-api + # Get log from camera API wrapper log-api: docker logs -f --tail 50 pyro-camera-api From 42ac53b0fc193a5746af7c449d5cafcade947046 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:26:26 +0200 Subject: [PATCH 06/12] build: move engine and camera API images to bookworm bullseye LTS ended in August 2026: its security repo now serves an expired release file and apt-get update fails in every build. --- Dockerfile | 4 ++-- pyro_camera_api/Dockerfile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 72314de0..44b4364d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ---- Builder: only used for git-based deps (needs git) ---- -FROM python:3.11.13-slim-bullseye AS git-deps +FROM python:3.11.13-slim-bookworm AS git-deps RUN apt-get update && \ apt-get install -y --no-install-recommends git \ @@ -12,7 +12,7 @@ COPY ./requirements-git.txt /tmp/requirements-git.txt RUN uv pip install --no-cache --target=/tmp/git-packages -r /tmp/requirements-git.txt # ---- Runtime ---- -FROM python:3.11.13-slim-bullseye +FROM python:3.11.13-slim-bookworm ENV LANG="C.UTF-8" \ PYTHONUNBUFFERED=1 \ diff --git a/pyro_camera_api/Dockerfile b/pyro_camera_api/Dockerfile index ef8a1dcf..798dda1f 100644 --- a/pyro_camera_api/Dockerfile +++ b/pyro_camera_api/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9.16-slim +FROM python:3.9-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ From 8a7b0726e3a3a5157249a8acd21d5c66847f6d6c Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:44:24 +0200 Subject: [PATCH 07/12] fix(temporal): bound uploads and queue, bind to loopback, publish image, compose profile Per-frame size limit and pending-job cap on POST /jobs, service bound to 127.0.0.1 on the host network, image added to the build-and-push matrix so make run can pull it, and the service placed behind the temporal compose profile so disabled stacks do not start it. --- .env.example | 4 +++- .github/workflows/build-push-image.yml | 3 +++ CLAUDE.md | 4 ++-- README.md | 10 +++++----- docker-compose.yml | 2 ++ pyro_temporal_api/Dockerfile | 3 ++- pyro_temporal_api/pyro_temporal_api/jobs.py | 8 ++++++++ pyro_temporal_api/pyro_temporal_api/main.py | 16 +++++++++++++--- pyro_temporal_api/tests/test_jobs.py | 17 ++++++++++++++++- 9 files changed, 54 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index d4eadcd1..8a547423 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,6 @@ PYRO_ENGINE_VERSION=latest # Stuck-PTZ detector (auto-reboots a PTZ camera that stops rotating during patrol). # Enabled by default. Set to "false" to disable. ENABLE_STUCK_DETECTOR=true -TEMPORAL_API_URL=http://localhost:8082 +# Optional temporal validation of alerts: both lines together, plus data/model_onnx.zip +# COMPOSE_PROFILES=temporal +# TEMPORAL_API_URL=http://localhost:8082 diff --git a/.github/workflows/build-push-image.yml b/.github/workflows/build-push-image.yml index 3e5333ee..7975d44a 100644 --- a/.github/workflows/build-push-image.yml +++ b/.github/workflows/build-push-image.yml @@ -21,6 +21,9 @@ jobs: - image: pyronear/pyro-camera-api context: pyro_camera_api dockerfile: pyro_camera_api/Dockerfile + - image: pyronear/pyro-temporal-api + context: pyro_temporal_api + dockerfile: pyro_temporal_api/Dockerfile steps: - name: Checkout repository diff --git a/CLAUDE.md b/CLAUDE.md index 7cbde8b3..9312d67f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ For each `cam_id`, the engine periodically fetches a JSON file at `{bbox_mask_ur ### Temporal validation (optional) -When `TEMPORAL_API_URL` is set, `Engine` keeps the last `temporal_window` (10) inference JPEGs and their YOLO boxes per `cam_id`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors fail open (alert sent unvalidated). The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. +The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` keeps the last `temporal_window` (10) inference JPEGs and their YOLO boxes per `cam_id`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors fail open (alert sent unvalidated). The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. ### Stream-awareness @@ -96,7 +96,7 @@ Day is determined by IR-channel analysis (`is_day_time(strategy="ir")`): if `max ### Environment Variables (`.env`) -Key vars used at runtime: `LAT`, `LON`, `API_URL`, `API_TOKEN`, `CAM_USER`, `CAM_PWD`, `MEDIAMTX_SERVER_IP`, `ROUTER_IP`, `ROUTER_USER`, `ROUTER_PASSWORD`, `ENABLE_ROUTER_REBOOT`, `TEMPORAL_API_URL` (e.g. `http://localhost:8082`, unset = no temporal validation), `TEMPORAL_MODEL_PATH`. +Key vars used at runtime: `LAT`, `LON`, `API_URL`, `API_TOKEN`, `CAM_USER`, `CAM_PWD`, `MEDIAMTX_SERVER_IP`, `ROUTER_IP`, `ROUTER_USER`, `ROUTER_PASSWORD`, `ENABLE_ROUTER_REBOOT`, `COMPOSE_PROFILES=temporal` + `TEMPORAL_API_URL` (e.g. `http://localhost:8082`; both unset = no temporal validation), `TEMPORAL_MODEL_PATH`. ### Legacy direct-camera module diff --git a/README.md b/README.md index 2b9fa039..5650b8c7 100644 --- a/README.md +++ b/README.md @@ -113,15 +113,15 @@ CAM_USER=my_dummy_login CAM_PWD=my_dummy_pwd MEDIAMTX_SERVER_IP=1.2.3.4 PYRO_ENGINE_VERSION=latest -TEMPORAL_API_URL=http://localhost:8082 ``` `PYRO_ENGINE_VERSION` controls which Docker image tag is pulled for the services (defaults to `latest` if unset). -`TEMPORAL_API_URL` enables the optional temporal validation: while an alert is ongoing, the engine sends the last -frames and boxes of that camera pose to `pyro_temporal_api` and only uploads the alert once the temporal smoke -model confirms it. Leave it unset to send alerts as before. The service needs `data/model_onnx.zip`, exported from -a [temporal-model](https://github.com/pyronear/temporal-model) release with `temporal-export-onnx`. +Optional temporal validation of alerts: with `COMPOSE_PROFILES=temporal` and `TEMPORAL_API_URL=http://localhost:8082` +in `.env`, the stack also starts `pyro_temporal_api`. While an alert is ongoing, the engine sends the last frames and +boxes of that camera pose to it and only uploads the alert once the temporal smoke model confirms it. Leave both unset +to send alerts as before. The service needs `data/model_onnx.zip`, exported from a +[temporal-model](https://github.com/pyronear/temporal-model) release with `temporal-export-onnx`. ### Data directory diff --git a/docker-compose.yml b/docker-compose.yml index 1e658d57..ba088a44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,9 @@ services: max-file: "5" + # Optional: started only with COMPOSE_PROFILES=temporal (needs data/model_onnx.zip) pyro-temporal-api: + profiles: ["temporal"] build: context: ./pyro_temporal_api image: pyronear/pyro-temporal-api:${PYRO_ENGINE_VERSION:-latest} diff --git a/pyro_temporal_api/Dockerfile b/pyro_temporal_api/Dockerfile index 4bf7e524..66fd73cc 100644 --- a/pyro_temporal_api/Dockerfile +++ b/pyro_temporal_api/Dockerfile @@ -22,4 +22,5 @@ WORKDIR /usr/src/app COPY pyproject.toml ./pyproject.toml COPY pyro_temporal_api ./pyro_temporal_api -CMD ["uvicorn", "pyro_temporal_api.main:app", "--host", "0.0.0.0", "--port", "8082"] +# host network mode: bind to loopback, only the engine on the same box talks to this service +CMD ["uvicorn", "pyro_temporal_api.main:app", "--host", "127.0.0.1", "--port", "8082"] diff --git a/pyro_temporal_api/pyro_temporal_api/jobs.py b/pyro_temporal_api/pyro_temporal_api/jobs.py index 054a2894..29fae01b 100644 --- a/pyro_temporal_api/pyro_temporal_api/jobs.py +++ b/pyro_temporal_api/pyro_temporal_api/jobs.py @@ -22,6 +22,12 @@ logger = logging.getLogger(__name__) MAX_JOBS_KEPT = 100 +# One job per camera pose per round is the normal load; more means a stuck worker or a flood. +MAX_PENDING = 16 + + +class QueueFullError(RuntimeError): + """Raised by submit() when too many jobs are still waiting to be scored.""" @dataclass @@ -99,6 +105,8 @@ def __init__(self, model: Any) -> None: self._worker.start() def submit(self, cam_id: str, frames: list[tuple[str, bytes]], boxes: list[list[list[float]]]) -> Job: + if self._queue.qsize() >= MAX_PENDING: + raise QueueFullError(f"{MAX_PENDING} jobs already pending") job = Job(job_id=uuid.uuid4().hex, cam_id=cam_id, frames=frames, boxes=boxes) with self._lock: self._jobs[job.job_id] = job diff --git a/pyro_temporal_api/pyro_temporal_api/main.py b/pyro_temporal_api/pyro_temporal_api/main.py index 36a14758..dfa218f0 100644 --- a/pyro_temporal_api/pyro_temporal_api/main.py +++ b/pyro_temporal_api/pyro_temporal_api/main.py @@ -22,13 +22,15 @@ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from temporal_model.core.onnx_model import OnnxTemporalModel -from pyro_temporal_api.jobs import JobStore +from pyro_temporal_api.jobs import JobStore, QueueFullError logging.basicConfig(format="%(asctime)s | %(levelname)s: %(message)s", level=logging.INFO) logger = logging.getLogger(__name__) MODEL_PATH = Path(os.environ.get("TEMPORAL_MODEL_PATH", "data/model_onnx.zip")) MAX_FRAMES = 20 +# Inference frames are a few hundred KB; anything larger is not an engine frame. +MAX_FRAME_BYTES = 4 * 1024 * 1024 @asynccontextmanager @@ -78,8 +80,16 @@ async def submit_job( if any(not isinstance(b, list) or len(b) != 5 for b in fb): raise HTTPException(status_code=422, detail="each box must be [x1, y1, x2, y2, conf]") - payload = [(Path(f.filename or "frame").stem, await f.read()) for f in frames] - job = request.app.state.jobs.submit(cam_id, payload, parsed) + payload = [] + for f in frames: + data = await f.read(MAX_FRAME_BYTES + 1) + if len(data) > MAX_FRAME_BYTES: + raise HTTPException(status_code=413, detail=f"frame {f.filename} exceeds {MAX_FRAME_BYTES} bytes") + payload.append((Path(f.filename or "frame").stem, data)) + try: + job = request.app.state.jobs.submit(cam_id, payload, parsed) + except QueueFullError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc return {"job_id": job.job_id} diff --git a/pyro_temporal_api/tests/test_jobs.py b/pyro_temporal_api/tests/test_jobs.py index d08f7e6a..6f3c470d 100644 --- a/pyro_temporal_api/tests/test_jobs.py +++ b/pyro_temporal_api/tests/test_jobs.py @@ -11,7 +11,7 @@ from fastapi.testclient import TestClient from PIL import Image -from pyro_temporal_api import main +from pyro_temporal_api import jobs, main from pyro_temporal_api.jobs import JobStore, _to_frame_detections @@ -122,3 +122,18 @@ def test_to_frame_detections_keeps_empty_frames(): dets = _to_frame_detections(frames, [[], [[0, 0, 1, 1, 0.5]]]) assert dets["a"].detections == [] assert len(dets["b"].detections) == 1 + + +def test_oversized_frame_is_rejected(client, monkeypatch): + c, _ = client + monkeypatch.setattr(main, "MAX_FRAME_BYTES", 100) + r = _submit(c, n_frames=1) + assert r.status_code == 413 + + +def test_full_queue_returns_503(client, monkeypatch): + c, _ = client + monkeypatch.setattr(jobs, "MAX_PENDING", 0) + r = _submit(c, n_frames=1) + assert r.status_code == 503 + assert "pending" in r.json()["detail"] From 86aed286354146588fc1e0d8d1dcdb9e8cc09c3f Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 15:25:36 +0200 Subject: [PATCH 08/12] chore(temporal): pin temporal-model-core to the latest PR 76 commit --- pyro_temporal_api/pyproject.toml | 2 +- pyro_temporal_api/requirements.txt | 2 +- pyro_temporal_api/uv.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyro_temporal_api/pyproject.toml b/pyro_temporal_api/pyproject.toml index 1bdc2f5f..e2462364 100644 --- a/pyro_temporal_api/pyproject.toml +++ b/pyro_temporal_api/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "uvicorn[standard]>=0.30.0,<0.31", "python-multipart>=0.0.9,<0.1", "pyyaml>=6.0", - "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@449e6b049cc988bd7458ba101249680587eb0b33#subdirectory=core", + "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@daaf239792797abe0fe76b5d5d0b5a6812f0d1fe#subdirectory=core", ] [dependency-groups] diff --git a/pyro_temporal_api/requirements.txt b/pyro_temporal_api/requirements.txt index e0d091e9..5697fc8f 100644 --- a/pyro_temporal_api/requirements.txt +++ b/pyro_temporal_api/requirements.txt @@ -77,7 +77,7 @@ pyyaml==6.0.3 # uvicorn starlette==0.37.2 # via fastapi -temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@449e6b049cc988bd7458ba101249680587eb0b33#subdirectory=core +temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@daaf239792797abe0fe76b5d5d0b5a6812f0d1fe#subdirectory=core # via pyro-temporal-api tqdm==4.70.0 # via huggingface-hub diff --git a/pyro_temporal_api/uv.lock b/pyro_temporal_api/uv.lock index a5e3123c..c00c1620 100644 --- a/pyro_temporal_api/uv.lock +++ b/pyro_temporal_api/uv.lock @@ -419,7 +419,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.110.0,<0.111" }, { name = "python-multipart", specifier = ">=0.0.9,<0.1" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=449e6b049cc988bd7458ba101249680587eb0b33" }, + { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=daaf239792797abe0fe76b5d5d0b5a6812f0d1fe" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.31" }, ] @@ -505,7 +505,7 @@ wheels = [ [[package]] name = "temporal-model-core" version = "0.1.0" -source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=449e6b049cc988bd7458ba101249680587eb0b33#449e6b049cc988bd7458ba101249680587eb0b33" } +source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=daaf239792797abe0fe76b5d5d0b5a6812f0d1fe#daaf239792797abe0fe76b5d5d0b5a6812f0d1fe" } dependencies = [ { name = "huggingface-hub" }, { name = "numpy" }, From 4228cb4f9bcd54785343e4b0b8e261755b13e887 Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 9 Sep 2026 08:23:20 +0200 Subject: [PATCH 09/12] chore(temporal): bump temporal-model-core pin to PR 76 head (ef76860) --- pyro_temporal_api/pyproject.toml | 2 +- pyro_temporal_api/requirements.txt | 2 +- pyro_temporal_api/uv.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyro_temporal_api/pyproject.toml b/pyro_temporal_api/pyproject.toml index e2462364..0125cb9d 100644 --- a/pyro_temporal_api/pyproject.toml +++ b/pyro_temporal_api/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "uvicorn[standard]>=0.30.0,<0.31", "python-multipart>=0.0.9,<0.1", "pyyaml>=6.0", - "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@daaf239792797abe0fe76b5d5d0b5a6812f0d1fe#subdirectory=core", + "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@ef76860863e737dbb67e961e5015c79441e8f9da#subdirectory=core", ] [dependency-groups] diff --git a/pyro_temporal_api/requirements.txt b/pyro_temporal_api/requirements.txt index 5697fc8f..e9e1900e 100644 --- a/pyro_temporal_api/requirements.txt +++ b/pyro_temporal_api/requirements.txt @@ -77,7 +77,7 @@ pyyaml==6.0.3 # uvicorn starlette==0.37.2 # via fastapi -temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@daaf239792797abe0fe76b5d5d0b5a6812f0d1fe#subdirectory=core +temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@ef76860863e737dbb67e961e5015c79441e8f9da#subdirectory=core # via pyro-temporal-api tqdm==4.70.0 # via huggingface-hub diff --git a/pyro_temporal_api/uv.lock b/pyro_temporal_api/uv.lock index c00c1620..dc75fc86 100644 --- a/pyro_temporal_api/uv.lock +++ b/pyro_temporal_api/uv.lock @@ -419,7 +419,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.110.0,<0.111" }, { name = "python-multipart", specifier = ">=0.0.9,<0.1" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=daaf239792797abe0fe76b5d5d0b5a6812f0d1fe" }, + { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=ef76860863e737dbb67e961e5015c79441e8f9da" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.31" }, ] @@ -505,7 +505,7 @@ wheels = [ [[package]] name = "temporal-model-core" version = "0.1.0" -source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=daaf239792797abe0fe76b5d5d0b5a6812f0d1fe#daaf239792797abe0fe76b5d5d0b5a6812f0d1fe" } +source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=ef76860863e737dbb67e961e5015c79441e8f9da#ef76860863e737dbb67e961e5015c79441e8f9da" } dependencies = [ { name = "huggingface-hub" }, { name = "numpy" }, From a87b779b36a7ab827c41e4219e40b1580f304bfc Mon Sep 17 00:00:00 2001 From: Mateo Date: Sat, 12 Sep 2026 12:43:23 +0200 Subject: [PATCH 10/12] chore(temporal): pin temporal-model-core to the PR 76 merge commit (fb7f6ca) --- pyro_temporal_api/pyproject.toml | 2 +- pyro_temporal_api/requirements.txt | 2 +- pyro_temporal_api/uv.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyro_temporal_api/pyproject.toml b/pyro_temporal_api/pyproject.toml index 0125cb9d..469b3c64 100644 --- a/pyro_temporal_api/pyproject.toml +++ b/pyro_temporal_api/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "uvicorn[standard]>=0.30.0,<0.31", "python-multipart>=0.0.9,<0.1", "pyyaml>=6.0", - "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@ef76860863e737dbb67e961e5015c79441e8f9da#subdirectory=core", + "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@fb7f6ca855cafa8a2ca0981ac54487c63892f6ad#subdirectory=core", ] [dependency-groups] diff --git a/pyro_temporal_api/requirements.txt b/pyro_temporal_api/requirements.txt index e9e1900e..decc3ceb 100644 --- a/pyro_temporal_api/requirements.txt +++ b/pyro_temporal_api/requirements.txt @@ -77,7 +77,7 @@ pyyaml==6.0.3 # uvicorn starlette==0.37.2 # via fastapi -temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@ef76860863e737dbb67e961e5015c79441e8f9da#subdirectory=core +temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@fb7f6ca855cafa8a2ca0981ac54487c63892f6ad#subdirectory=core # via pyro-temporal-api tqdm==4.70.0 # via huggingface-hub diff --git a/pyro_temporal_api/uv.lock b/pyro_temporal_api/uv.lock index dc75fc86..af62e595 100644 --- a/pyro_temporal_api/uv.lock +++ b/pyro_temporal_api/uv.lock @@ -419,7 +419,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.110.0,<0.111" }, { name = "python-multipart", specifier = ">=0.0.9,<0.1" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=ef76860863e737dbb67e961e5015c79441e8f9da" }, + { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=fb7f6ca855cafa8a2ca0981ac54487c63892f6ad" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.31" }, ] @@ -505,7 +505,7 @@ wheels = [ [[package]] name = "temporal-model-core" version = "0.1.0" -source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=ef76860863e737dbb67e961e5015c79441e8f9da#ef76860863e737dbb67e961e5015c79441e8f9da" } +source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=fb7f6ca855cafa8a2ca0981ac54487c63892f6ad#fb7f6ca855cafa8a2ca0981ac54487c63892f6ad" } dependencies = [ { name = "huggingface-hub" }, { name = "numpy" }, From 131d4ec03758809aabe2fbb53108ff9b42fcf098 Mon Sep 17 00:00:00 2001 From: Mateo Date: Sat, 12 Sep 2026 16:07:11 +0200 Subject: [PATCH 11/12] fix(engine): keep an ended event until its temporal verdict is in An event ending while the temporal job was pending was dropped by _end_event, so a positive verdict never uploaded the frames held during scoring. The state now survives until the verdict: positive stages the held frames, negative drops the event without resubmitting. --- pyroengine/engine.py | 93 ++++++++++++++++++++--------------- tests/test_engine_temporal.py | 33 +++++++++++++ 2 files changed, 87 insertions(+), 39 deletions(-) diff --git a/pyroengine/engine.py b/pyroengine/engine.py index 853bb99e..435a2f31 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -238,11 +238,12 @@ def _end_event(self, cam_key: str) -> None: if entry[4]: # is_staged: belongs to the event that just ended window[i] = (entry[0], entry[1], [], entry[3], True, entry[5]) - def _temporal_gate(self, cam_key: str, cam_id: str) -> bool: + def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bool: """Return True when this event may be staged: temporal verdict positive, or service unavailable. Round N submits the frame window; round N+1 reads the verdict. A negative verdict resubmits - the window (now one frame longer) so the event can still be confirmed later. A transport + the window (now one frame longer) so the event can still be confirmed later; with + ``resubmit`` False (the event already ended) a negative verdict just drops it. A transport error or a failed job fails open: the alert goes out as without temporal validation. """ state = self._states[cam_key] @@ -266,14 +267,19 @@ def _temporal_gate(self, cam_key: str, cam_id: str) -> bool: return True logger.info( f"Camera '{cam_id}' - temporal model rejected the alert " - f"(probability: {prob_str}, tubes: {verdict.get('n_tubes')}), resubmitting" + f"(probability: {prob_str}, tubes: {verdict.get('n_tubes')}), " + + ("resubmitting" if resubmit else "event over, dropped") ) + if not resubmit: + return False else: logger.error( f"Camera '{cam_id}' - temporal job failed ({result.get('error')}), alert sent unvalidated" ) state["temporal_validated"] = True return True + elif not resubmit: + return False window = list(state["temporal_frames"]) state["temporal_job"] = self.temporal.submit(cam_id, window) logger.info(f"Camera '{cam_id}' - temporal validation submitted ({len(window)} frames), alert held") @@ -408,8 +414,6 @@ def predict( if self.temporal is not None and encoded_bytes is not None: frame_id = f"{cam_key}_{datetime.now(timezone.utc).strftime('%Y-%m-%dT%H-%M-%S')}" state["temporal_frames"].append((frame_id, encoded_bytes, np.round(preds[:, :5], 4).tolist())) - if not self._states[cam_key]["ongoing"]: - self._end_event(cam_key) if self.save_captured_frames: self._local_backup(frame, cam_id, is_alert=False, encoded_bytes=encoded_bytes) @@ -419,43 +423,54 @@ def predict( pred_str = "Wildfire detected" if conf > self.conf_thresh else "No wildfire" logger.info(f"{device_str}{pred_str} (confidence: {conf:.2%})") - # Alert (use ongoing so hysteresis-relaxed threshold keeps staging frames during a dip) - if self._states[cam_key]["ongoing"] and len(self.api_client) > 0 and isinstance(cam_id, str): - if self.temporal is not None and not self._temporal_gate(cam_key, cam_id): - return float(conf) - state = self._states[cam_key] - # Collect every bbox the predictor emitted across the window; treat these as - # tracked locations and backfill missing per-frame bboxes from raw preds with conf=0. - tracked = [b[:4] for _, _, bbs, _, _, _ in state["last_predictions"] for b in bbs] - tracked_arr = np.array(tracked, dtype=np.float64) if tracked else np.empty((0, 4)) - - # Freeze one square crop box per cluster of tracked bboxes so every frame of the - # event is cropped at the same location, even when individual bboxes move. - full_size = next(((cc.full_w, cc.full_h) for cc, *_ in state["last_predictions"] if cc is not None), None) - if tracked and full_size is not None: - self._update_event_crop_boxes(cam_key, tracked, *full_size) - - # Carry the last seen bbox forward onto frames with no detection so the alert keeps a - # crop at the same location instead of a blank/placeholder frame (conf 0 flags the carry). - last_seen: list = [] - for idx, (crop_, preds_, bboxes, ts, is_staged, jpeg_bytes) in enumerate(state["last_predictions"]): - if is_staged: - if bboxes: - last_seen = bboxes - continue - bboxes = self._backfill_bboxes(bboxes, preds_, tracked_arr) - if not bboxes and last_seen: - bboxes = [[b[0], b[1], b[2], b[3], 0.0] for b in last_seen] - if bboxes: - last_seen = bboxes - crop_boxes = ( - self._assign_crop_boxes(bboxes, cam_key, *full_size) if bboxes and full_size is not None else None - ) - self._stage_alert(crop_, cam_id, ts, bboxes, jpeg_bytes, crop_boxes) - state["last_predictions"][idx] = (crop_, preds_, bboxes, ts, True, jpeg_bytes) + # Alert (use ongoing so hysteresis-relaxed threshold keeps staging frames during a dip). + # An event that ended while its temporal verdict is pending keeps its state until the + # verdict is in: a positive one still uploads the frames held while the smoke was scored. + alerting = (state["ongoing"] or state["temporal_job"] is not None) and len(self.api_client) > 0 + if ( + alerting + and isinstance(cam_id, str) + and (self.temporal is None or self._temporal_gate(cam_key, cam_id, resubmit=state["ongoing"])) + ): + self._stage_event(cam_key, cam_id) + if not state["ongoing"] and state["temporal_job"] is None: + self._end_event(cam_key) return float(conf) + def _stage_event(self, cam_key: str, cam_id: str) -> None: + """Stage every not-yet-staged frame of the window as an alert, cropped at the event's fire locations.""" + state = self._states[cam_key] + # Collect every bbox the predictor emitted across the window; treat these as + # tracked locations and backfill missing per-frame bboxes from raw preds with conf=0. + tracked = [b[:4] for _, _, bbs, _, _, _ in state["last_predictions"] for b in bbs] + tracked_arr = np.array(tracked, dtype=np.float64) if tracked else np.empty((0, 4)) + + # Freeze one square crop box per cluster of tracked bboxes so every frame of the + # event is cropped at the same location, even when individual bboxes move. + full_size = next(((cc.full_w, cc.full_h) for cc, *_ in state["last_predictions"] if cc is not None), None) + if tracked and full_size is not None: + self._update_event_crop_boxes(cam_key, tracked, *full_size) + + # Carry the last seen bbox forward onto frames with no detection so the alert keeps a + # crop at the same location instead of a blank/placeholder frame (conf 0 flags the carry). + last_seen: list = [] + for idx, (crop_, preds_, bboxes, ts, is_staged, jpeg_bytes) in enumerate(state["last_predictions"]): + if is_staged: + if bboxes: + last_seen = bboxes + continue + bboxes = self._backfill_bboxes(bboxes, preds_, tracked_arr) + if not bboxes and last_seen: + bboxes = [[b[0], b[1], b[2], b[3], 0.0] for b in last_seen] + if bboxes: + last_seen = bboxes + crop_boxes = ( + self._assign_crop_boxes(bboxes, cam_key, *full_size) if bboxes and full_size is not None else None + ) + self._stage_alert(crop_, cam_id, ts, bboxes, jpeg_bytes, crop_boxes) + state["last_predictions"][idx] = (crop_, preds_, bboxes, ts, True, jpeg_bytes) + @staticmethod def _fit_box( box: Tuple[float, float, float, float], img_w: float, img_h: float diff --git a/tests/test_engine_temporal.py b/tests/test_engine_temporal.py index 7fdd53a4..41bd62b3 100644 --- a/tests/test_engine_temporal.py +++ b/tests/test_engine_temporal.py @@ -136,3 +136,36 @@ def test_disabled_without_url(tmp_path, mock_wildfire_image): state = _fire(engine, mock_wildfire_image, n=2) assert state["ongoing"] assert len(state["temporal_frames"]) == 0 + + +EMPTY = np.empty((5, 0)) +DONE = lambda positive: {"status": "done", "verdict": {"is_positive": positive, "probability": 0.5, "n_tubes": 1}} + + +def test_event_ending_during_validation_still_uploads_on_positive(tmp_path, mock_wildfire_image, mock_forest_image): + temporal = FakeTemporal([{"status": "pending"}, {"status": "pending"}, DONE(True)]) + engine = _engine(tmp_path, temporal) + state = _fire(engine, mock_wildfire_image) # submitted, held + engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) # still ongoing (hysteresis), pending + engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) # event over, verdict still pending: kept + assert state["ongoing"] is False + assert state["temporal_job"] == "job1" + assert len(engine._alerts) == 0 + + engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) # positive verdict: held frames go out + assert len(engine._alerts) > 0 + assert state["temporal_job"] is None + assert state["temporal_validated"] is False # event closed after staging + assert not temporal.results + + +def test_event_ending_during_validation_dropped_on_negative(tmp_path, mock_wildfire_image, mock_forest_image): + temporal = FakeTemporal([{"status": "pending"}, {"status": "pending"}, DONE(False)]) + engine = _engine(tmp_path, temporal) + state = _fire(engine, mock_wildfire_image) + for _ in range(3): + engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) + assert state["ongoing"] is False + assert state["temporal_job"] is None # no resubmission for an ended event + assert len(temporal.submitted) == 1 + assert len(engine._alerts) == 0 From b37a3a3376cf285d4710b8a4ec439e1c6950ae2d Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 14 Oct 2026 17:54:42 +0200 Subject: [PATCH 12/12] feat(temporal): cache ViT embeddings across jobs, pin temporal-model split export Pins temporal-model-core to 7c1500e (feat/onnx-embedding-cache): the ONNX package is now backbone + head, the runtime embeds only real slots and keeps an LRU of per-slot embeddings keyed by (frame id, crop box). The engine resubmits the same window one frame longer each round, so a job now mostly pays the ViT for the new frame. The verdict reports embeddings_cached / embeddings_new; a fused (format 1) package still loads with a warning. README points at the model 0.6.0 release (pyro-dataset v4.7.0), to be re-exported with temporal-export-onnx for the split graphs. --- CLAUDE.md | 2 +- README.md | 8 ++++++-- pyro_temporal_api/pyproject.toml | 2 +- pyro_temporal_api/pyro_temporal_api/jobs.py | 4 ++++ pyro_temporal_api/pyro_temporal_api/main.py | 20 ++++++++++++++++---- pyro_temporal_api/requirements.txt | 2 +- pyro_temporal_api/tests/test_jobs.py | 8 +++++++- pyro_temporal_api/uv.lock | 4 ++-- 8 files changed, 38 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9312d67f..8d60382d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ For each `cam_id`, the engine periodically fetches a JSON file at `{bbox_mask_ur ### Temporal validation (optional) -The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` keeps the last `temporal_window` (10) inference JPEGs and their YOLO boxes per `cam_id`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors fail open (alert sent unvalidated). The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. +The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` keeps the last `temporal_window` (10) inference JPEGs and their YOLO boxes per `cam_id`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors fail open (alert sent unvalidated). The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. A format 2 package (split `backbone.onnx` + `head.onnx`) lets the service embed only real frames and cache per-slot ViT embeddings across jobs (`EMBEDDING_CACHE_SIZE` in `main.py`, keyed by frame id + crop box); the verdict reports `embeddings_cached` / `embeddings_new` per job. ### Stream-awareness diff --git a/README.md b/README.md index 6865bae2..a70adfdb 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,12 @@ PYRO_ENGINE_VERSION=latest Optional temporal validation of alerts: with `COMPOSE_PROFILES=temporal` and `TEMPORAL_API_URL=http://localhost:8082` in `.env`, the stack also starts `pyro_temporal_api`. While an alert is ongoing, the engine sends the last frames and boxes of that camera pose to it and only uploads the alert once the temporal smoke model confirms it. Leave both unset -to send alerts as before. The service needs `data/model_onnx.zip`, exported from a -[temporal-model](https://github.com/pyronear/temporal-model) release with `temporal-export-onnx`. +to send alerts as before. The service needs `data/model_onnx.zip`: the [temporal-model](https://github.com/pyronear/temporal-model) +release on Hugging Face (`pyronear/temporal-model`, revision `v0.6.0`, trained on pyro-dataset v4.7.0), re-exported +with `temporal-export-onnx` so the package carries the split backbone/head graphs. With those, the service embeds +only real frames and keeps per-frame ViT embeddings across rounds, so re-scoring a window one frame longer only costs +the new frame (as long as the tube's crop window does not move). A fused single-graph package still loads, without +these savings. ### Data directory diff --git a/pyro_temporal_api/pyproject.toml b/pyro_temporal_api/pyproject.toml index 469b3c64..2a4ebda7 100644 --- a/pyro_temporal_api/pyproject.toml +++ b/pyro_temporal_api/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "uvicorn[standard]>=0.30.0,<0.31", "python-multipart>=0.0.9,<0.1", "pyyaml>=6.0", - "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@fb7f6ca855cafa8a2ca0981ac54487c63892f6ad#subdirectory=core", + "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@7c1500ef9d6538b36666ce7a43f0e08ca175592e#subdirectory=core", ] [dependency-groups] diff --git a/pyro_temporal_api/pyro_temporal_api/jobs.py b/pyro_temporal_api/pyro_temporal_api/jobs.py index 29fae01b..368b7bec 100644 --- a/pyro_temporal_api/pyro_temporal_api/jobs.py +++ b/pyro_temporal_api/pyro_temporal_api/jobs.py @@ -76,6 +76,7 @@ def _to_frame_detections(frames: list[Any], boxes: list[list[list[float]]]) -> d def score(model: Any, job: Job) -> dict[str, Any]: """Run the temporal model on a job's window; frames live on disk only for the call.""" + hits, misses = getattr(model, "cache_hits", 0), getattr(model, "cache_misses", 0) with tempfile.TemporaryDirectory(prefix="temporal_job_") as td: paths = [] for frame_id, data in job.frames: @@ -90,6 +91,9 @@ def score(model: Any, job: Job) -> dict[str, Any]: "is_positive": bool(out.is_positive), "probability": max(probs) if probs else None, "n_tubes": len(kept), + # ViT slots served from the embedding cache vs embedded for this job (0/0 without a cache) + "embeddings_cached": getattr(model, "cache_hits", 0) - hits, + "embeddings_new": getattr(model, "cache_misses", 0) - misses, } diff --git a/pyro_temporal_api/pyro_temporal_api/main.py b/pyro_temporal_api/pyro_temporal_api/main.py index dfa218f0..1d0b1d74 100644 --- a/pyro_temporal_api/pyro_temporal_api/main.py +++ b/pyro_temporal_api/pyro_temporal_api/main.py @@ -29,26 +29,38 @@ MODEL_PATH = Path(os.environ.get("TEMPORAL_MODEL_PATH", "data/model_onnx.zip")) MAX_FRAMES = 20 +# Per-slot ViT embeddings kept across jobs, keyed by (frame id, crop box): the engine resubmits the +# same window one frame longer every round, so most slots were embedded on the previous job. +# One entry is 1.5 KB (ViT-S); a pose contributes ~10 frames x tubes per job. +EMBEDDING_CACHE_SIZE = 4096 # Inference frames are a few hundred KB; anything larger is not an engine frame. MAX_FRAME_BYTES = 4 * 1024 * 1024 @asynccontextmanager async def lifespan(app: FastAPI): - model = OnnxTemporalModel.from_package(MODEL_PATH) + manifest = read_manifest(MODEL_PATH) + split = manifest.get("format_version", 1) >= 2 + if not split: + logger.warning( + "%s is a format 1 package (fused classifier.onnx): every slot is embedded on every job. " + "Re-export it with temporal-export-onnx to skip padded slots and cache embeddings.", + MODEL_PATH, + ) + model = OnnxTemporalModel.from_package(MODEL_PATH, embedding_cache_size=EMBEDDING_CACHE_SIZE if split else 0) app.state.jobs = JobStore(model) - app.state.model_version = model_version(MODEL_PATH) + app.state.model_version = manifest.get("model_version") logger.info("Loaded temporal model %s (version %s)", MODEL_PATH, app.state.model_version) yield -def model_version(path: Path) -> str | None: +def read_manifest(path: Path) -> dict: import zipfile import yaml with zipfile.ZipFile(path) as zf: - return yaml.safe_load(zf.read("manifest.yaml")).get("model_version") + return dict(yaml.safe_load(zf.read("manifest.yaml"))) app = FastAPI(title="Pyro Temporal API", lifespan=lifespan) diff --git a/pyro_temporal_api/requirements.txt b/pyro_temporal_api/requirements.txt index decc3ceb..ae02645b 100644 --- a/pyro_temporal_api/requirements.txt +++ b/pyro_temporal_api/requirements.txt @@ -77,7 +77,7 @@ pyyaml==6.0.3 # uvicorn starlette==0.37.2 # via fastapi -temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@fb7f6ca855cafa8a2ca0981ac54487c63892f6ad#subdirectory=core +temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@7c1500ef9d6538b36666ce7a43f0e08ca175592e#subdirectory=core # via pyro-temporal-api tqdm==4.70.0 # via huggingface-hub diff --git a/pyro_temporal_api/tests/test_jobs.py b/pyro_temporal_api/tests/test_jobs.py index 6f3c470d..c3d3fe1f 100644 --- a/pyro_temporal_api/tests/test_jobs.py +++ b/pyro_temporal_api/tests/test_jobs.py @@ -70,7 +70,13 @@ def test_job_lifecycle_and_conversion(client): body = c.get(f"/jobs/{job_id}").json() assert body["status"] == "done" assert body["cam_id"] == "192.168.1.10_1" - assert body["verdict"] == {"is_positive": True, "probability": 0.8, "n_tubes": 1} + assert body["verdict"] == { + "is_positive": True, + "probability": 0.8, + "n_tubes": 1, + "embeddings_cached": 0, + "embeddings_new": 0, + } frames, dets = model.calls[0] assert [f.frame_id for f in frames] == [f"192.168.1.10_1_2026-09-08T10-00-0{i}" for i in range(3)] diff --git a/pyro_temporal_api/uv.lock b/pyro_temporal_api/uv.lock index af62e595..37f08025 100644 --- a/pyro_temporal_api/uv.lock +++ b/pyro_temporal_api/uv.lock @@ -419,7 +419,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.110.0,<0.111" }, { name = "python-multipart", specifier = ">=0.0.9,<0.1" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=fb7f6ca855cafa8a2ca0981ac54487c63892f6ad" }, + { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=7c1500ef9d6538b36666ce7a43f0e08ca175592e" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.31" }, ] @@ -505,7 +505,7 @@ wheels = [ [[package]] name = "temporal-model-core" version = "0.1.0" -source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=fb7f6ca855cafa8a2ca0981ac54487c63892f6ad#fb7f6ca855cafa8a2ca0981ac54487c63892f6ad" } +source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=7c1500ef9d6538b36666ce7a43f0e08ca175592e#7c1500ef9d6538b36666ce7a43f0e08ca175592e" } dependencies = [ { name = "huggingface-hub" }, { name = "numpy" },