feat(api): FastAPI REST backend for React frontend
Browse files## Summary
- Add FastAPI REST API endpoints for stroke segmentation
- Endpoints: GET /api/cases, POST /api/segment, GET /files/{path}
- CORS configured for HF Spaces frontend
- Sync endpoint handlers for proper threadpool execution
- Update Dockerfile for FastAPI deployment
- 8 backend tests, 58 frontend tests passing
## Changes
- Add src/stroke_deepisles_demo/api/ module (main.py, routes.py, schemas.py)
- Add tests/api/test_endpoints.py
- Update pyproject.toml with [api] extra dependencies
- Update Dockerfile CMD for uvicorn
- Update CI to install api extras
Addresses CodeRabbit feedback:
- Fixed flaky waitFor test
- Changed async def to def for sync handlers
- Fixed exception handling
- Updated spec documentation
- .github/workflows/ci.yml +4 -4
- Dockerfile +9 -14
- docs/specs/frontend/36-frontend-without-gradio-hf-spaces.md +123 -105
- frontend/README.md +50 -58
- frontend/src/components/__tests__/NiiVueViewer.test.tsx +7 -7
- pyproject.toml +18 -6
- requirements.txt +3 -6
- src/stroke_deepisles_demo/api/__init__.py +5 -0
- src/stroke_deepisles_demo/api/main.py +50 -0
- src/stroke_deepisles_demo/api/routes.py +94 -0
- src/stroke_deepisles_demo/api/schemas.py +27 -0
- tests/api/__init__.py +1 -0
- tests/api/test_endpoints.py +145 -0
- uv.lock +231 -6
.github/workflows/ci.yml
CHANGED
|
@@ -26,7 +26,7 @@ jobs:
|
|
| 26 |
run: uv python install 3.12
|
| 27 |
|
| 28 |
- name: Install dependencies
|
| 29 |
-
run: uv sync
|
| 30 |
|
| 31 |
- name: Lint with ruff
|
| 32 |
run: uv run ruff check .
|
|
@@ -46,7 +46,7 @@ jobs:
|
|
| 46 |
run: uv python install 3.12
|
| 47 |
|
| 48 |
- name: Install dependencies
|
| 49 |
-
run: uv sync
|
| 50 |
|
| 51 |
- name: Type check with mypy
|
| 52 |
run: uv run mypy src/
|
|
@@ -63,7 +63,7 @@ jobs:
|
|
| 63 |
run: uv python install 3.12
|
| 64 |
|
| 65 |
- name: Install dependencies
|
| 66 |
-
run: uv sync
|
| 67 |
|
| 68 |
- name: Run tests
|
| 69 |
run: uv run pytest --cov --cov-report=xml
|
|
@@ -100,7 +100,7 @@ jobs:
|
|
| 100 |
run: uv python install 3.12
|
| 101 |
|
| 102 |
- name: Install dependencies
|
| 103 |
-
run: uv sync
|
| 104 |
|
| 105 |
- name: Run integration tests
|
| 106 |
run: uv run pytest -m integration --timeout=600
|
|
|
|
| 26 |
run: uv python install 3.12
|
| 27 |
|
| 28 |
- name: Install dependencies
|
| 29 |
+
run: uv sync --extra api --extra gradio
|
| 30 |
|
| 31 |
- name: Lint with ruff
|
| 32 |
run: uv run ruff check .
|
|
|
|
| 46 |
run: uv python install 3.12
|
| 47 |
|
| 48 |
- name: Install dependencies
|
| 49 |
+
run: uv sync --extra api --extra gradio
|
| 50 |
|
| 51 |
- name: Type check with mypy
|
| 52 |
run: uv run mypy src/
|
|
|
|
| 63 |
run: uv python install 3.12
|
| 64 |
|
| 65 |
- name: Install dependencies
|
| 66 |
+
run: uv sync --extra api --extra gradio
|
| 67 |
|
| 68 |
- name: Run tests
|
| 69 |
run: uv run pytest --cov --cov-report=xml
|
|
|
|
| 100 |
run: uv python install 3.12
|
| 101 |
|
| 102 |
- name: Install dependencies
|
| 103 |
+
run: uv sync --extra api --extra gradio
|
| 104 |
|
| 105 |
- name: Run integration tests
|
| 106 |
run: uv run pytest -m integration --timeout=600
|
Dockerfile
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
# Dockerfile for Hugging Face Spaces deployment
|
| 2 |
# Base: DeepISLES image with nnU-Net, SEALS, and all ML dependencies
|
| 3 |
-
# See: docs/specs/
|
| 4 |
#
|
| 5 |
# IMPORTANT: During Docker build, GPU is NOT available.
|
| 6 |
# All GPU operations happen at runtime only.
|
|
@@ -31,12 +31,8 @@ WORKDIR /home/user/demo
|
|
| 31 |
# Copy requirements first for better layer caching
|
| 32 |
COPY --chown=1000:1000 requirements.txt /home/user/demo/requirements.txt
|
| 33 |
|
| 34 |
-
# Copy local packages (Custom Components) BEFORE pip install
|
| 35 |
-
# This is required because requirements.txt refers to ./packages/niivueviewer
|
| 36 |
-
COPY --chown=1000:1000 packages/ /home/user/demo/packages/
|
| 37 |
-
|
| 38 |
# Install Python dependencies into SYSTEM Python (NOT conda env)
|
| 39 |
-
# DeepISLES conda env is Python 3.8, but
|
| 40 |
# We'll shell out to conda env for inference only
|
| 41 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 42 |
|
|
@@ -44,7 +40,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|
| 44 |
COPY --chown=1000:1000 pyproject.toml /home/user/demo/pyproject.toml
|
| 45 |
COPY --chown=1000:1000 README.md /home/user/demo/README.md
|
| 46 |
COPY --chown=1000:1000 src/ /home/user/demo/src/
|
| 47 |
-
COPY --chown=1000:1000 app.py /home/user/demo/app.py
|
| 48 |
|
| 49 |
# Copy adapter script for subprocess invocation of DeepISLES
|
| 50 |
# This script runs in the conda env (Py3.8) and is called via subprocess
|
|
@@ -67,18 +62,18 @@ ENV DEEPISLES_PATH=/app
|
|
| 67 |
ENV HF_HOME=/home/user/demo/cache
|
| 68 |
|
| 69 |
# Create directories for data with proper permissions
|
| 70 |
-
|
| 71 |
-
|
|
|
|
| 72 |
|
| 73 |
# Switch to non-root user (required by HF Spaces)
|
| 74 |
USER user
|
| 75 |
|
| 76 |
-
# Expose the
|
| 77 |
EXPOSE 7860
|
| 78 |
|
| 79 |
# Reset ENTRYPOINT from base image
|
| 80 |
ENTRYPOINT []
|
| 81 |
|
| 82 |
-
# Run
|
| 83 |
-
|
| 84 |
-
CMD ["python", "-m", "stroke_deepisles_demo.ui.app"]
|
|
|
|
| 1 |
+
# Dockerfile for Hugging Face Spaces deployment (FastAPI backend)
|
| 2 |
# Base: DeepISLES image with nnU-Net, SEALS, and all ML dependencies
|
| 3 |
+
# See: docs/specs/frontend/36-frontend-without-gradio-hf-spaces.md
|
| 4 |
#
|
| 5 |
# IMPORTANT: During Docker build, GPU is NOT available.
|
| 6 |
# All GPU operations happen at runtime only.
|
|
|
|
| 31 |
# Copy requirements first for better layer caching
|
| 32 |
COPY --chown=1000:1000 requirements.txt /home/user/demo/requirements.txt
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
# Install Python dependencies into SYSTEM Python (NOT conda env)
|
| 35 |
+
# DeepISLES conda env is Python 3.8, but FastAPI needs Python 3.10+
|
| 36 |
# We'll shell out to conda env for inference only
|
| 37 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 38 |
|
|
|
|
| 40 |
COPY --chown=1000:1000 pyproject.toml /home/user/demo/pyproject.toml
|
| 41 |
COPY --chown=1000:1000 README.md /home/user/demo/README.md
|
| 42 |
COPY --chown=1000:1000 src/ /home/user/demo/src/
|
|
|
|
| 43 |
|
| 44 |
# Copy adapter script for subprocess invocation of DeepISLES
|
| 45 |
# This script runs in the conda env (Py3.8) and is called via subprocess
|
|
|
|
| 62 |
ENV HF_HOME=/home/user/demo/cache
|
| 63 |
|
| 64 |
# Create directories for data with proper permissions
|
| 65 |
+
# CRITICAL: /tmp/stroke-results is required for FastAPI StaticFiles mount
|
| 66 |
+
RUN mkdir -p /home/user/demo/data /home/user/demo/results /home/user/demo/cache /tmp/stroke-results && \
|
| 67 |
+
chown -R 1000:1000 /home/user/demo /tmp/stroke-results
|
| 68 |
|
| 69 |
# Switch to non-root user (required by HF Spaces)
|
| 70 |
USER user
|
| 71 |
|
| 72 |
+
# Expose the API port (HF Spaces expects 7860)
|
| 73 |
EXPOSE 7860
|
| 74 |
|
| 75 |
# Reset ENTRYPOINT from base image
|
| 76 |
ENTRYPOINT []
|
| 77 |
|
| 78 |
+
# Run FastAPI with uvicorn (module path: stroke_deepisles_demo.api.main:app)
|
| 79 |
+
CMD ["uvicorn", "stroke_deepisles_demo.api.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
docs/specs/frontend/36-frontend-without-gradio-hf-spaces.md
CHANGED
|
@@ -71,7 +71,7 @@ You **need both** because:
|
|
| 71 |
│ Endpoints: │
|
| 72 |
│ - GET /api/cases │
|
| 73 |
│ - POST /api/segment │
|
| 74 |
-
│ - GET /
|
| 75 |
│ │
|
| 76 |
│ Sleeps after 48h inactivity │
|
| 77 |
└─────────────────────────────────────┘
|
|
@@ -81,9 +81,13 @@ You **need both** because:
|
|
| 81 |
|
| 82 |
## Project Structure
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
```
|
| 85 |
-
stroke-
|
| 86 |
-
├── frontend/ # Static Space
|
| 87 |
│ ├── src/
|
| 88 |
│ │ ├── components/
|
| 89 |
│ │ │ ├── NiiVueViewer.tsx
|
|
@@ -99,26 +103,43 @@ stroke-viewer/
|
|
| 99 |
│ │ ├── App.tsx
|
| 100 |
│ │ ├── main.tsx
|
| 101 |
│ │ └── index.css
|
|
|
|
| 102 |
│ ├── public/
|
| 103 |
│ ├── index.html
|
| 104 |
│ ├── vite.config.ts
|
|
|
|
|
|
|
| 105 |
│ ├── tsconfig.json
|
| 106 |
│ ├── package.json
|
| 107 |
│ └── README.md # HF Spaces YAML config
|
| 108 |
│
|
| 109 |
-
├──
|
| 110 |
-
│ ├── api/
|
| 111 |
│ │ ├── __init__.py
|
| 112 |
│ │ ├── main.py # FastAPI app
|
| 113 |
│ │ ├── routes.py # API endpoints
|
| 114 |
│ │ └── schemas.py # Pydantic models
|
| 115 |
-
│ ├──
|
| 116 |
-
│ ├──
|
| 117 |
-
│
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
│
|
| 119 |
-
|
|
|
|
|
|
|
| 120 |
```
|
| 121 |
|
|
|
|
|
|
|
|
|
|
| 122 |
---
|
| 123 |
|
| 124 |
## Frontend Implementation
|
|
@@ -706,37 +727,44 @@ Built with React, TypeScript, Tailwind CSS, and Vite.
|
|
| 706 |
|
| 707 |
## Backend Implementation
|
| 708 |
|
| 709 |
-
|
|
|
|
|
|
|
| 710 |
|
| 711 |
-
|
| 712 |
-
fastapi==0.124.2
|
| 713 |
-
uvicorn[standard]==0.34.0
|
| 714 |
-
pydantic==2.10.4
|
| 715 |
-
python-multipart>=0.0.18
|
| 716 |
|
| 717 |
-
|
| 718 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 719 |
```
|
| 720 |
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
|
|
|
| 726 |
|
| 727 |
-
|
|
|
|
|
|
|
|
|
|
| 728 |
|
| 729 |
```python
|
| 730 |
-
"""FastAPI
|
| 731 |
|
| 732 |
import os
|
| 733 |
-
import re
|
| 734 |
|
| 735 |
from fastapi import FastAPI
|
| 736 |
from fastapi.middleware.cors import CORSMiddleware
|
| 737 |
from fastapi.staticfiles import StaticFiles
|
| 738 |
|
| 739 |
-
from api.routes import router
|
| 740 |
|
| 741 |
app = FastAPI(
|
| 742 |
title="Stroke Segmentation API",
|
|
@@ -744,11 +772,10 @@ app = FastAPI(
|
|
| 744 |
version="1.0.0",
|
| 745 |
)
|
| 746 |
|
| 747 |
-
# CORS
|
| 748 |
-
# Also supports PR previews: pr-{n}--{org}--{space}.hf.space
|
| 749 |
FRONTEND_ORIGIN = os.environ.get("FRONTEND_ORIGIN", "")
|
| 750 |
CORS_ORIGINS = [
|
| 751 |
-
"http://localhost:5173", #
|
| 752 |
"http://localhost:3000", # Alternative local port
|
| 753 |
]
|
| 754 |
if FRONTEND_ORIGIN:
|
|
@@ -757,12 +784,7 @@ if FRONTEND_ORIGIN:
|
|
| 757 |
app.add_middleware(
|
| 758 |
CORSMiddleware,
|
| 759 |
allow_origins=CORS_ORIGINS,
|
| 760 |
-
|
| 761 |
-
# - Production: https://{org}--stroke-viewer-frontend.hf.space
|
| 762 |
-
# - PR preview: https://{org}--stroke-viewer-frontend--pr-{N}.hf.space
|
| 763 |
-
# - Branch: https://{org}--stroke-viewer-frontend--{branch}.hf.space
|
| 764 |
-
# Pattern: anything--stroke-viewer-frontend, optionally followed by --anything
|
| 765 |
-
allow_origin_regex=r"https://.*--stroke-viewer-frontend(--.*)?\.hf\.space",
|
| 766 |
allow_credentials=True,
|
| 767 |
allow_methods=["*"],
|
| 768 |
allow_headers=["*"],
|
|
@@ -771,17 +793,19 @@ app.add_middleware(
|
|
| 771 |
# API routes
|
| 772 |
app.include_router(router, prefix="/api")
|
| 773 |
|
| 774 |
-
#
|
| 775 |
-
|
| 776 |
-
|
|
|
|
| 777 |
|
| 778 |
|
| 779 |
@app.get("/")
|
| 780 |
async def root():
|
|
|
|
| 781 |
return {"status": "healthy", "service": "stroke-segmentation-api"}
|
| 782 |
```
|
| 783 |
|
| 784 |
-
###
|
| 785 |
|
| 786 |
```python
|
| 787 |
"""API route handlers."""
|
|
@@ -791,15 +815,15 @@ import uuid
|
|
| 791 |
from pathlib import Path
|
| 792 |
|
| 793 |
from fastapi import APIRouter, HTTPException, Request
|
| 794 |
-
from api.schemas import SegmentRequest, SegmentResponse, CasesResponse
|
| 795 |
|
|
|
|
| 796 |
from stroke_deepisles_demo.data import list_case_ids
|
| 797 |
from stroke_deepisles_demo.pipeline import run_pipeline_on_case
|
| 798 |
from stroke_deepisles_demo.metrics import compute_volume_ml
|
| 799 |
|
| 800 |
router = APIRouter()
|
| 801 |
|
| 802 |
-
# Base directory for results
|
| 803 |
RESULTS_BASE = Path("/tmp/stroke-results")
|
| 804 |
|
| 805 |
|
|
@@ -813,7 +837,6 @@ def get_backend_base_url(request: Request) -> str:
|
|
| 813 |
env_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
|
| 814 |
if env_url:
|
| 815 |
return env_url
|
| 816 |
-
# Fall back to request origin (works for local dev)
|
| 817 |
return str(request.base_url).rstrip("/")
|
| 818 |
|
| 819 |
|
|
@@ -831,7 +854,7 @@ async def get_cases():
|
|
| 831 |
async def run_segmentation(request: Request, body: SegmentRequest):
|
| 832 |
"""Run DeepISLES segmentation on a case."""
|
| 833 |
try:
|
| 834 |
-
# Generate unique run ID to avoid conflicts
|
| 835 |
run_id = str(uuid.uuid4())[:8]
|
| 836 |
output_dir = RESULTS_BASE / run_id
|
| 837 |
|
|
@@ -850,14 +873,11 @@ async def run_segmentation(request: Request, body: SegmentRequest):
|
|
| 850 |
except Exception:
|
| 851 |
pass
|
| 852 |
|
| 853 |
-
# Build
|
| 854 |
-
# Files are at: /tmp/stroke-results/{run_id}/{case_id}/{filename}
|
| 855 |
-
# Served at: /files/{run_id}/{case_id}/{filename}
|
| 856 |
backend_url = get_backend_base_url(request)
|
| 857 |
dwi_filename = result.input_files["dwi"].name
|
| 858 |
pred_filename = result.prediction_mask.name
|
| 859 |
|
| 860 |
-
# URL path: /files/{run_id}/{case_id}/{filename}
|
| 861 |
file_path_prefix = f"/files/{run_id}/{result.case_id}"
|
| 862 |
|
| 863 |
return SegmentResponse(
|
|
@@ -868,29 +888,34 @@ async def run_segmentation(request: Request, body: SegmentRequest):
|
|
| 868 |
dwiUrl=f"{backend_url}{file_path_prefix}/{dwi_filename}",
|
| 869 |
predictionUrl=f"{backend_url}{file_path_prefix}/{pred_filename}",
|
| 870 |
)
|
| 871 |
-
|
| 872 |
except Exception as e:
|
| 873 |
raise HTTPException(status_code=500, detail=str(e))
|
| 874 |
```
|
| 875 |
|
| 876 |
-
###
|
| 877 |
|
| 878 |
```python
|
| 879 |
-
"""Pydantic schemas for API."""
|
| 880 |
|
| 881 |
from pydantic import BaseModel
|
| 882 |
|
| 883 |
|
| 884 |
class CasesResponse(BaseModel):
|
|
|
|
|
|
|
| 885 |
cases: list[str]
|
| 886 |
|
| 887 |
|
| 888 |
class SegmentRequest(BaseModel):
|
|
|
|
|
|
|
| 889 |
case_id: str
|
| 890 |
fast_mode: bool = True
|
| 891 |
|
| 892 |
|
| 893 |
class SegmentResponse(BaseModel):
|
|
|
|
|
|
|
| 894 |
caseId: str
|
| 895 |
diceScore: float | None
|
| 896 |
volumeMl: float | None
|
|
@@ -899,7 +924,9 @@ class SegmentResponse(BaseModel):
|
|
| 899 |
predictionUrl: str
|
| 900 |
```
|
| 901 |
|
| 902 |
-
###
|
|
|
|
|
|
|
| 903 |
|
| 904 |
```dockerfile
|
| 905 |
# CRITICAL: Must use isleschallenge/deepisles base image
|
|
@@ -907,26 +934,17 @@ class SegmentResponse(BaseModel):
|
|
| 907 |
# - PyTorch with CUDA support
|
| 908 |
# - Pre-installed DeepISLES model weights (~18GB)
|
| 909 |
# - All medical imaging dependencies (nibabel, nnunet, etc.)
|
| 910 |
-
#
|
| 911 |
-
# Using python:3.11-slim would require manually downloading weights
|
| 912 |
-
# and reinstalling all CUDA/PyTorch dependencies - not feasible.
|
| 913 |
FROM isleschallenge/deepisles:latest
|
| 914 |
|
| 915 |
WORKDIR /app
|
| 916 |
|
| 917 |
-
# Copy the
|
| 918 |
-
# This is required because requirements.txt references "stroke-deepisles-demo @ file:."
|
| 919 |
COPY pyproject.toml .
|
| 920 |
COPY src/ src/
|
| 921 |
COPY README.md .
|
| 922 |
|
| 923 |
-
#
|
| 924 |
-
|
| 925 |
-
COPY backend/requirements.txt .
|
| 926 |
-
|
| 927 |
-
# Install API dependencies (FastAPI, uvicorn) + local package
|
| 928 |
-
# Note: Base image already has torch, nibabel, etc.
|
| 929 |
-
RUN pip install --no-cache-dir -r requirements.txt
|
| 930 |
|
| 931 |
# Create results directory (used by StaticFiles mount)
|
| 932 |
RUN mkdir -p /tmp/stroke-results
|
|
@@ -938,8 +956,8 @@ ENV DEEPISLES_DIRECT_INVOCATION=1
|
|
| 938 |
# Expose port (HF Spaces expects 7860)
|
| 939 |
EXPOSE 7860
|
| 940 |
|
| 941 |
-
# Run FastAPI
|
| 942 |
-
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
| 943 |
```
|
| 944 |
|
| 945 |
**CRITICAL: GPU Required**
|
|
@@ -992,48 +1010,48 @@ FastAPI backend running DeepISLES stroke lesion segmentation.
|
|
| 992 |
### Frontend (Local Development)
|
| 993 |
|
| 994 |
```bash
|
| 995 |
-
|
| 996 |
-
npm create vite@latest stroke-viewer-frontend -- --template react-ts
|
| 997 |
-
cd stroke-viewer-frontend
|
| 998 |
-
|
| 999 |
-
# Install dependencies
|
| 1000 |
-
npm install @niivue/niivue
|
| 1001 |
-
npm install -D tailwindcss @tailwindcss/vite
|
| 1002 |
|
| 1003 |
-
#
|
|
|
|
| 1004 |
|
| 1005 |
# Run dev server
|
| 1006 |
npm run dev
|
| 1007 |
# Opens http://localhost:5173
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1008 |
```
|
| 1009 |
|
| 1010 |
### Backend (Local Development)
|
| 1011 |
|
| 1012 |
```bash
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
# Create virtual environment
|
| 1016 |
-
python -m venv venv
|
| 1017 |
-
source venv/bin/activate
|
| 1018 |
|
| 1019 |
-
# Install dependencies
|
| 1020 |
-
pip install -
|
| 1021 |
|
| 1022 |
# Run server
|
| 1023 |
-
uvicorn api.main:app --reload --port 7860
|
| 1024 |
# Opens http://localhost:7860
|
|
|
|
|
|
|
|
|
|
| 1025 |
```
|
| 1026 |
|
| 1027 |
### Deploy to HuggingFace
|
| 1028 |
|
| 1029 |
```bash
|
| 1030 |
-
# Frontend (Static Space)
|
| 1031 |
cd frontend
|
|
|
|
| 1032 |
huggingface-cli repo create stroke-viewer-frontend --type space --space-sdk static
|
| 1033 |
huggingface-cli upload stroke-viewer-frontend ./dist . --repo-type space
|
| 1034 |
|
| 1035 |
-
# Backend (Docker Space)
|
| 1036 |
-
|
| 1037 |
huggingface-cli repo create stroke-viewer-api --type space --space-sdk docker
|
| 1038 |
huggingface-cli upload stroke-viewer-api . . --repo-type space
|
| 1039 |
```
|
|
@@ -1068,35 +1086,35 @@ No additional env vars needed - uses existing stroke-deepisles-demo configuratio
|
|
| 1068 |
|
| 1069 |
## Next Steps
|
| 1070 |
|
| 1071 |
-
1. Create `frontend/` directory with
|
| 1072 |
-
2. Create `
|
| 1073 |
-
3.
|
| 1074 |
-
4.
|
| 1075 |
-
5.
|
|
|
|
| 1076 |
|
| 1077 |
---
|
| 1078 |
|
| 1079 |
## Dependencies Summary (Verified Dec 11, 2025)
|
| 1080 |
|
| 1081 |
-
**Frontend (npm) -
|
| 1082 |
| Package | Version | Notes |
|
| 1083 |
|---------|---------|-------|
|
| 1084 |
-
| react |
|
| 1085 |
-
| react-dom |
|
| 1086 |
-
| @niivue/niivue | 0.65.0 | Latest stable |
|
| 1087 |
-
| typescript | 5.
|
| 1088 |
-
| vite |
|
| 1089 |
-
| tailwindcss | 4.1.
|
| 1090 |
-
| @tailwindcss/vite | 4.1.
|
| 1091 |
-
| @vitejs/plugin-react |
|
| 1092 |
-
|
| 1093 |
-
**Backend (pip) -
|
| 1094 |
| Package | Version | Notes |
|
| 1095 |
|---------|---------|-------|
|
| 1096 |
-
| fastapi | 0.
|
| 1097 |
-
| uvicorn[standard] | 0.
|
| 1098 |
-
| pydantic |
|
| 1099 |
-
| python-multipart | >=0.0.18 | Required by FastAPI |
|
| 1100 |
|
| 1101 |
-
**Node.js:** >= 20.0.0 (required for Vite
|
| 1102 |
**Python:** >= 3.11 (recommended for FastAPI)
|
|
|
|
| 71 |
│ Endpoints: │
|
| 72 |
│ - GET /api/cases │
|
| 73 |
│ - POST /api/segment │
|
| 74 |
+
│ - GET /files/{run_id}/{case}/... │
|
| 75 |
│ │
|
| 76 |
│ Sleeps after 48h inactivity │
|
| 77 |
└─────────────────────────────────────┘
|
|
|
|
| 81 |
|
| 82 |
## Project Structure
|
| 83 |
|
| 84 |
+
This is an **existing monorepo** (`stroke-deepisles-demo`), NOT a new project. The frontend is
|
| 85 |
+
added alongside the existing Python package. The "backend" is the existing `src/stroke_deepisles_demo/`
|
| 86 |
+
package with a new `api/` submodule.
|
| 87 |
+
|
| 88 |
```
|
| 89 |
+
stroke-deepisles-demo/ # EXISTING monorepo
|
| 90 |
+
├── frontend/ # NEW: React + NiiVue (Static Space)
|
| 91 |
│ ├── src/
|
| 92 |
│ │ ├── components/
|
| 93 |
│ │ │ ├── NiiVueViewer.tsx
|
|
|
|
| 103 |
│ │ ├── App.tsx
|
| 104 |
│ │ ├── main.tsx
|
| 105 |
│ │ └── index.css
|
| 106 |
+
│ ├── e2e/ # Playwright E2E tests
|
| 107 |
│ ├── public/
|
| 108 |
│ ├── index.html
|
| 109 |
│ ├── vite.config.ts
|
| 110 |
+
│ ├── vitest.config.ts
|
| 111 |
+
│ ├── playwright.config.ts
|
| 112 |
│ ├── tsconfig.json
|
| 113 |
│ ├── package.json
|
| 114 |
│ └── README.md # HF Spaces YAML config
|
| 115 |
│
|
| 116 |
+
├── src/stroke_deepisles_demo/ # EXISTING Python package (Docker Space)
|
| 117 |
+
│ ├── api/ # NEW: FastAPI REST API submodule
|
| 118 |
│ │ ├── __init__.py
|
| 119 |
│ │ ├── main.py # FastAPI app
|
| 120 |
│ │ ├── routes.py # API endpoints
|
| 121 |
│ │ └── schemas.py # Pydantic models
|
| 122 |
+
│ ├── core/ # Config, logging (existing)
|
| 123 |
+
│ ├── data/ # Data adapters (existing)
|
| 124 |
+
│ ├── inference/ # DeepISLES integration (existing)
|
| 125 |
+
│ ├── ui/ # Gradio UI (being replaced)
|
| 126 |
+
│ ├── pipeline.py # ML pipeline (existing)
|
| 127 |
+
│ └── metrics.py # Metrics computation (existing)
|
| 128 |
+
│
|
| 129 |
+
├── tests/
|
| 130 |
+
│ ├── api/ # NEW: API endpoint tests
|
| 131 |
+
│ │ ├── __init__.py
|
| 132 |
+
│ │ └── test_endpoints.py
|
| 133 |
+
│ └── ... # Existing tests
|
| 134 |
│
|
| 135 |
+
├── Dockerfile # Docker for HF Spaces (existing)
|
| 136 |
+
├── pyproject.toml # Python package config (existing)
|
| 137 |
+
└── README.md
|
| 138 |
```
|
| 139 |
|
| 140 |
+
**Key difference from a greenfield project:** We're adding `frontend/` and `src/stroke_deepisles_demo/api/`
|
| 141 |
+
to an existing codebase, NOT creating separate `frontend/` and `backend/` directories.
|
| 142 |
+
|
| 143 |
---
|
| 144 |
|
| 145 |
## Frontend Implementation
|
|
|
|
| 727 |
|
| 728 |
## Backend Implementation
|
| 729 |
|
| 730 |
+
The backend is the **existing** `src/stroke_deepisles_demo/` Python package. We add a new
|
| 731 |
+
`api/` submodule for FastAPI endpoints. This keeps all Python code in one package with
|
| 732 |
+
proper imports (e.g., `from stroke_deepisles_demo.api.routes import router`).
|
| 733 |
|
| 734 |
+
### pyproject.toml (additions)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
|
| 736 |
+
Add these dependencies to the existing `pyproject.toml`:
|
| 737 |
+
|
| 738 |
+
```toml
|
| 739 |
+
[project.optional-dependencies]
|
| 740 |
+
api = [
|
| 741 |
+
"fastapi>=0.115.0",
|
| 742 |
+
"uvicorn[standard]>=0.32.0",
|
| 743 |
+
]
|
| 744 |
```
|
| 745 |
|
| 746 |
+
### src/stroke_deepisles_demo/api/__init__.py
|
| 747 |
+
|
| 748 |
+
```python
|
| 749 |
+
"""FastAPI REST API for stroke segmentation."""
|
| 750 |
+
|
| 751 |
+
from stroke_deepisles_demo.api.main import app
|
| 752 |
|
| 753 |
+
__all__ = ["app"]
|
| 754 |
+
```
|
| 755 |
+
|
| 756 |
+
### src/stroke_deepisles_demo/api/main.py
|
| 757 |
|
| 758 |
```python
|
| 759 |
+
"""FastAPI application for stroke segmentation API."""
|
| 760 |
|
| 761 |
import os
|
|
|
|
| 762 |
|
| 763 |
from fastapi import FastAPI
|
| 764 |
from fastapi.middleware.cors import CORSMiddleware
|
| 765 |
from fastapi.staticfiles import StaticFiles
|
| 766 |
|
| 767 |
+
from stroke_deepisles_demo.api.routes import router
|
| 768 |
|
| 769 |
app = FastAPI(
|
| 770 |
title="Stroke Segmentation API",
|
|
|
|
| 772 |
version="1.0.0",
|
| 773 |
)
|
| 774 |
|
| 775 |
+
# CORS configuration
|
|
|
|
| 776 |
FRONTEND_ORIGIN = os.environ.get("FRONTEND_ORIGIN", "")
|
| 777 |
CORS_ORIGINS = [
|
| 778 |
+
"http://localhost:5173", # Vite dev server
|
| 779 |
"http://localhost:3000", # Alternative local port
|
| 780 |
]
|
| 781 |
if FRONTEND_ORIGIN:
|
|
|
|
| 784 |
app.add_middleware(
|
| 785 |
CORSMiddleware,
|
| 786 |
allow_origins=CORS_ORIGINS,
|
| 787 |
+
allow_origin_regex=r"https://.*--stroke-viewer-frontend(--.*)?\\.hf\\.space",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
allow_credentials=True,
|
| 789 |
allow_methods=["*"],
|
| 790 |
allow_headers=["*"],
|
|
|
|
| 793 |
# API routes
|
| 794 |
app.include_router(router, prefix="/api")
|
| 795 |
|
| 796 |
+
# Static files for NIfTI results (only mount if directory exists)
|
| 797 |
+
RESULTS_DIR = "/tmp/stroke-results"
|
| 798 |
+
if os.path.exists(RESULTS_DIR):
|
| 799 |
+
app.mount("/files", StaticFiles(directory=RESULTS_DIR), name="files")
|
| 800 |
|
| 801 |
|
| 802 |
@app.get("/")
|
| 803 |
async def root():
|
| 804 |
+
"""Health check endpoint."""
|
| 805 |
return {"status": "healthy", "service": "stroke-segmentation-api"}
|
| 806 |
```
|
| 807 |
|
| 808 |
+
### src/stroke_deepisles_demo/api/routes.py
|
| 809 |
|
| 810 |
```python
|
| 811 |
"""API route handlers."""
|
|
|
|
| 815 |
from pathlib import Path
|
| 816 |
|
| 817 |
from fastapi import APIRouter, HTTPException, Request
|
|
|
|
| 818 |
|
| 819 |
+
from stroke_deepisles_demo.api.schemas import CasesResponse, SegmentRequest, SegmentResponse
|
| 820 |
from stroke_deepisles_demo.data import list_case_ids
|
| 821 |
from stroke_deepisles_demo.pipeline import run_pipeline_on_case
|
| 822 |
from stroke_deepisles_demo.metrics import compute_volume_ml
|
| 823 |
|
| 824 |
router = APIRouter()
|
| 825 |
|
| 826 |
+
# Base directory for results
|
| 827 |
RESULTS_BASE = Path("/tmp/stroke-results")
|
| 828 |
|
| 829 |
|
|
|
|
| 837 |
env_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
|
| 838 |
if env_url:
|
| 839 |
return env_url
|
|
|
|
| 840 |
return str(request.base_url).rstrip("/")
|
| 841 |
|
| 842 |
|
|
|
|
| 854 |
async def run_segmentation(request: Request, body: SegmentRequest):
|
| 855 |
"""Run DeepISLES segmentation on a case."""
|
| 856 |
try:
|
| 857 |
+
# Generate unique run ID to avoid conflicts
|
| 858 |
run_id = str(uuid.uuid4())[:8]
|
| 859 |
output_dir = RESULTS_BASE / run_id
|
| 860 |
|
|
|
|
| 873 |
except Exception:
|
| 874 |
pass
|
| 875 |
|
| 876 |
+
# Build absolute file URLs
|
|
|
|
|
|
|
| 877 |
backend_url = get_backend_base_url(request)
|
| 878 |
dwi_filename = result.input_files["dwi"].name
|
| 879 |
pred_filename = result.prediction_mask.name
|
| 880 |
|
|
|
|
| 881 |
file_path_prefix = f"/files/{run_id}/{result.case_id}"
|
| 882 |
|
| 883 |
return SegmentResponse(
|
|
|
|
| 888 |
dwiUrl=f"{backend_url}{file_path_prefix}/{dwi_filename}",
|
| 889 |
predictionUrl=f"{backend_url}{file_path_prefix}/{pred_filename}",
|
| 890 |
)
|
|
|
|
| 891 |
except Exception as e:
|
| 892 |
raise HTTPException(status_code=500, detail=str(e))
|
| 893 |
```
|
| 894 |
|
| 895 |
+
### src/stroke_deepisles_demo/api/schemas.py
|
| 896 |
|
| 897 |
```python
|
| 898 |
+
"""Pydantic schemas for API requests and responses."""
|
| 899 |
|
| 900 |
from pydantic import BaseModel
|
| 901 |
|
| 902 |
|
| 903 |
class CasesResponse(BaseModel):
|
| 904 |
+
"""Response for GET /api/cases."""
|
| 905 |
+
|
| 906 |
cases: list[str]
|
| 907 |
|
| 908 |
|
| 909 |
class SegmentRequest(BaseModel):
|
| 910 |
+
"""Request body for POST /api/segment."""
|
| 911 |
+
|
| 912 |
case_id: str
|
| 913 |
fast_mode: bool = True
|
| 914 |
|
| 915 |
|
| 916 |
class SegmentResponse(BaseModel):
|
| 917 |
+
"""Response for POST /api/segment."""
|
| 918 |
+
|
| 919 |
caseId: str
|
| 920 |
diceScore: float | None
|
| 921 |
volumeMl: float | None
|
|
|
|
| 924 |
predictionUrl: str
|
| 925 |
```
|
| 926 |
|
| 927 |
+
### Dockerfile (update existing)
|
| 928 |
+
|
| 929 |
+
The existing `Dockerfile` at project root needs to be updated for the API:
|
| 930 |
|
| 931 |
```dockerfile
|
| 932 |
# CRITICAL: Must use isleschallenge/deepisles base image
|
|
|
|
| 934 |
# - PyTorch with CUDA support
|
| 935 |
# - Pre-installed DeepISLES model weights (~18GB)
|
| 936 |
# - All medical imaging dependencies (nibabel, nnunet, etc.)
|
|
|
|
|
|
|
|
|
|
| 937 |
FROM isleschallenge/deepisles:latest
|
| 938 |
|
| 939 |
WORKDIR /app
|
| 940 |
|
| 941 |
+
# Copy the project
|
|
|
|
| 942 |
COPY pyproject.toml .
|
| 943 |
COPY src/ src/
|
| 944 |
COPY README.md .
|
| 945 |
|
| 946 |
+
# Install the package with API dependencies
|
| 947 |
+
RUN pip install --no-cache-dir -e ".[api]"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 948 |
|
| 949 |
# Create results directory (used by StaticFiles mount)
|
| 950 |
RUN mkdir -p /tmp/stroke-results
|
|
|
|
| 956 |
# Expose port (HF Spaces expects 7860)
|
| 957 |
EXPOSE 7860
|
| 958 |
|
| 959 |
+
# Run FastAPI (note: module path is stroke_deepisles_demo.api.main:app)
|
| 960 |
+
CMD ["uvicorn", "stroke_deepisles_demo.api.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
| 961 |
```
|
| 962 |
|
| 963 |
**CRITICAL: GPU Required**
|
|
|
|
| 1010 |
### Frontend (Local Development)
|
| 1011 |
|
| 1012 |
```bash
|
| 1013 |
+
cd frontend
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1014 |
|
| 1015 |
+
# Install dependencies (already configured in package.json)
|
| 1016 |
+
npm install
|
| 1017 |
|
| 1018 |
# Run dev server
|
| 1019 |
npm run dev
|
| 1020 |
# Opens http://localhost:5173
|
| 1021 |
+
|
| 1022 |
+
# Run tests
|
| 1023 |
+
npm test # Unit tests with Vitest
|
| 1024 |
+
npm run test:e2e # E2E tests with Playwright
|
| 1025 |
+
npm run test:coverage # Coverage report
|
| 1026 |
```
|
| 1027 |
|
| 1028 |
### Backend (Local Development)
|
| 1029 |
|
| 1030 |
```bash
|
| 1031 |
+
# From project root (stroke-deepisles-demo/)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1032 |
|
| 1033 |
+
# Install with API dependencies
|
| 1034 |
+
pip install -e ".[api]"
|
| 1035 |
|
| 1036 |
# Run server
|
| 1037 |
+
uvicorn stroke_deepisles_demo.api.main:app --reload --port 7860
|
| 1038 |
# Opens http://localhost:7860
|
| 1039 |
+
|
| 1040 |
+
# Run API tests
|
| 1041 |
+
pytest tests/api/ -v
|
| 1042 |
```
|
| 1043 |
|
| 1044 |
### Deploy to HuggingFace
|
| 1045 |
|
| 1046 |
```bash
|
| 1047 |
+
# Frontend (Static Space) - deploy from frontend/ directory
|
| 1048 |
cd frontend
|
| 1049 |
+
npm run build
|
| 1050 |
huggingface-cli repo create stroke-viewer-frontend --type space --space-sdk static
|
| 1051 |
huggingface-cli upload stroke-viewer-frontend ./dist . --repo-type space
|
| 1052 |
|
| 1053 |
+
# Backend (Docker Space) - deploy from project root
|
| 1054 |
+
# The Dockerfile at project root builds the full package including API
|
| 1055 |
huggingface-cli repo create stroke-viewer-api --type space --space-sdk docker
|
| 1056 |
huggingface-cli upload stroke-viewer-api . . --repo-type space
|
| 1057 |
```
|
|
|
|
| 1086 |
|
| 1087 |
## Next Steps
|
| 1088 |
|
| 1089 |
+
1. ✅ Create `frontend/` directory with React + NiiVue (DONE - PR #32 merged)
|
| 1090 |
+
2. ✅ Create `src/stroke_deepisles_demo/api/` submodule with FastAPI (DONE)
|
| 1091 |
+
3. ✅ Create `tests/api/` with endpoint tests (DONE - 8 tests passing)
|
| 1092 |
+
4. Test locally: `npm run dev` + `uvicorn stroke_deepisles_demo.api.main:app`
|
| 1093 |
+
5. Create HuggingFace Spaces (one Static, one Docker)
|
| 1094 |
+
6. Deploy and test
|
| 1095 |
|
| 1096 |
---
|
| 1097 |
|
| 1098 |
## Dependencies Summary (Verified Dec 11, 2025)
|
| 1099 |
|
| 1100 |
+
**Frontend (npm) - ACTUAL VERSIONS (from package.json):**
|
| 1101 |
| Package | Version | Notes |
|
| 1102 |
|---------|---------|-------|
|
| 1103 |
+
| react | ^19.2.0 | React 19 client-only (safe from CVE-2025-55182) |
|
| 1104 |
+
| react-dom | ^19.2.0 | Must match react version |
|
| 1105 |
+
| @niivue/niivue | ^0.65.0 | Latest stable |
|
| 1106 |
+
| typescript | ~5.9.3 | Latest 5.9.x |
|
| 1107 |
+
| vite | ^7.2.4 | Latest v7 |
|
| 1108 |
+
| tailwindcss | ^4.1.17 | Latest v4 |
|
| 1109 |
+
| @tailwindcss/vite | ^4.1.17 | Must match tailwindcss |
|
| 1110 |
+
| @vitejs/plugin-react | ^5.1.1 | Latest stable |
|
| 1111 |
+
|
| 1112 |
+
**Backend (pip) - VERSIONS (from pyproject.toml):**
|
| 1113 |
| Package | Version | Notes |
|
| 1114 |
|---------|---------|-------|
|
| 1115 |
+
| fastapi | >=0.115.0 | Latest compatible |
|
| 1116 |
+
| uvicorn[standard] | >=0.32.0 | Latest stable |
|
| 1117 |
+
| pydantic | (bundled) | Included with FastAPI |
|
|
|
|
| 1118 |
|
| 1119 |
+
**Node.js:** >= 20.0.0 (required for Vite 7)
|
| 1120 |
**Python:** >= 3.11 (recommended for FastAPI)
|
frontend/README.md
CHANGED
|
@@ -1,73 +1,65 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
| 8 |
-
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
|
| 10 |
-
##
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
##
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
```
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
| 27 |
-
tseslint.configs.recommendedTypeChecked,
|
| 28 |
-
// Alternatively, use this for stricter rules
|
| 29 |
-
tseslint.configs.strictTypeChecked,
|
| 30 |
-
// Optionally, add this for stylistic rules
|
| 31 |
-
tseslint.configs.stylisticTypeChecked,
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
},
|
| 40 |
-
// other options...
|
| 41 |
-
},
|
| 42 |
-
},
|
| 43 |
-
])
|
| 44 |
```
|
| 45 |
|
| 46 |
-
|
| 47 |
|
| 48 |
-
|
| 49 |
-
// eslint.config.js
|
| 50 |
-
import reactX from 'eslint-plugin-react-x'
|
| 51 |
-
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
{
|
| 56 |
-
files: ['**/*.{ts,tsx}'],
|
| 57 |
-
extends: [
|
| 58 |
-
// Other configs...
|
| 59 |
-
// Enable lint rules for React
|
| 60 |
-
reactX.configs['recommended-typescript'],
|
| 61 |
-
// Enable lint rules for React DOM
|
| 62 |
-
reactDom.configs.recommended,
|
| 63 |
-
],
|
| 64 |
-
languageOptions: {
|
| 65 |
-
parserOptions: {
|
| 66 |
-
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
-
tsconfigRootDir: import.meta.dirname,
|
| 68 |
-
},
|
| 69 |
-
// other options...
|
| 70 |
-
},
|
| 71 |
-
},
|
| 72 |
-
])
|
| 73 |
```
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Stroke Lesion Viewer
|
| 3 |
+
emoji: 🧠
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: static
|
| 7 |
+
app_file: dist/index.html
|
| 8 |
+
app_build_command: npm run build
|
| 9 |
+
# CRITICAL: Vite 6 requires Node.js >= 20. HF Spaces defaults to Node 18.
|
| 10 |
+
# Without this, the build will fail or produce warnings.
|
| 11 |
+
nodejs_version: "20"
|
| 12 |
+
pinned: false
|
| 13 |
+
---
|
| 14 |
|
| 15 |
+
# Stroke Lesion Segmentation Viewer
|
| 16 |
|
| 17 |
+
Interactive 3D viewer for stroke lesion segmentation results using NiiVue.
|
| 18 |
|
| 19 |
+
Built with React, TypeScript, Tailwind CSS, and Vite.
|
|
|
|
| 20 |
|
| 21 |
+
## Features
|
| 22 |
|
| 23 |
+
- **NiiVue WebGL2 Viewer**: Pan, zoom, and navigate through NIfTI volumes
|
| 24 |
+
- **Real-time Segmentation**: Run DeepISLES inference on ISLES24 dataset cases
|
| 25 |
+
- **Metrics Display**: Dice score, volume (mL), processing time
|
| 26 |
|
| 27 |
+
## Architecture
|
| 28 |
|
| 29 |
+
This is the **frontend Static Space** of a two-Space deployment:
|
| 30 |
|
| 31 |
+
```
|
| 32 |
+
┌─────────────────────────────────────┐
|
| 33 |
+
│ HuggingFace Static Space │ ← You are here
|
| 34 |
+
│ stroke-viewer-frontend │
|
| 35 |
+
│ │
|
| 36 |
+
│ React 19 + TypeScript + Tailwind │
|
| 37 |
+
│ @niivue/niivue for 3D viewing │
|
| 38 |
+
└──────────────┬──────────────────────┘
|
| 39 |
+
│ HTTPS API calls
|
| 40 |
+
▼
|
| 41 |
+
┌─────────────────────────────────────┐
|
| 42 |
+
│ HuggingFace Docker Space │
|
| 43 |
+
│ stroke-viewer-api │
|
| 44 |
+
│ │
|
| 45 |
+
│ FastAPI + DeepISLES + PyTorch │
|
| 46 |
+
└─────────────────────────────────────┘
|
| 47 |
+
```
|
| 48 |
|
| 49 |
+
## Local Development
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
+
```bash
|
| 52 |
+
npm install
|
| 53 |
+
npm run dev # Start dev server at http://localhost:5173
|
| 54 |
+
npm test # Run unit tests
|
| 55 |
+
npm run test:e2e # Run E2E tests
|
| 56 |
+
npm run build # Production build
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
```
|
| 58 |
|
| 59 |
+
## Environment Variables
|
| 60 |
|
| 61 |
+
Set `VITE_API_URL` to point to your backend:
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
+
```bash
|
| 64 |
+
VITE_API_URL=http://localhost:7860 npm run dev
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
```
|
frontend/src/components/__tests__/NiiVueViewer.test.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
| 2 |
-
import { render, screen } from '@testing-library/react'
|
| 3 |
import { NiiVueViewer } from '../NiiVueViewer'
|
| 4 |
|
| 5 |
// Store mock function references so tests can verify calls
|
|
@@ -119,8 +119,8 @@ describe('NiiVueViewer', () => {
|
|
| 119 |
|
| 120 |
render(<NiiVueViewer {...defaultProps} onError={onError} />)
|
| 121 |
|
| 122 |
-
// Wait for error callback to be invoked
|
| 123 |
-
await
|
| 124 |
expect(onError).toHaveBeenCalledWith(errorMessage)
|
| 125 |
})
|
| 126 |
})
|
|
@@ -152,9 +152,9 @@ describe('NiiVueViewer', () => {
|
|
| 152 |
// Now reject the second load (stale)
|
| 153 |
rejectSecondLoad!(new Error('Stale load error'))
|
| 154 |
|
| 155 |
-
//
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
})
|
| 160 |
})
|
|
|
|
| 1 |
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
| 2 |
+
import { render, screen, waitFor } from '@testing-library/react'
|
| 3 |
import { NiiVueViewer } from '../NiiVueViewer'
|
| 4 |
|
| 5 |
// Store mock function references so tests can verify calls
|
|
|
|
| 119 |
|
| 120 |
render(<NiiVueViewer {...defaultProps} onError={onError} />)
|
| 121 |
|
| 122 |
+
// Wait for error callback to be invoked (use RTL's waitFor, not vi.waitFor)
|
| 123 |
+
await waitFor(() => {
|
| 124 |
expect(onError).toHaveBeenCalledWith(errorMessage)
|
| 125 |
})
|
| 126 |
})
|
|
|
|
| 152 |
// Now reject the second load (stale)
|
| 153 |
rejectSecondLoad!(new Error('Stale load error'))
|
| 154 |
|
| 155 |
+
// Flush async work (let rejection be processed) before asserting
|
| 156 |
+
// Using waitFor with negative assertions is flaky - it passes immediately
|
| 157 |
+
await new Promise(resolve => setTimeout(resolve, 0))
|
| 158 |
+
expect(onError).not.toHaveBeenCalledWith('Stale load error')
|
| 159 |
})
|
| 160 |
})
|
pyproject.toml
CHANGED
|
@@ -31,15 +31,22 @@ dependencies = [
|
|
| 31 |
"pydantic>=2.5.0",
|
| 32 |
"pydantic-settings>=2.1.0",
|
| 33 |
|
| 34 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
"gradio>=6.0.0,<7.0.0",
|
| 36 |
"matplotlib>=3.8.0",
|
| 37 |
-
|
| 38 |
-
# Custom component for NiiVue WebGL viewer (local package)
|
| 39 |
"gradio_niivueviewer",
|
| 40 |
-
|
| 41 |
-
# Networking
|
| 42 |
-
"requests>=2.0.0",
|
| 43 |
]
|
| 44 |
|
| 45 |
[project.scripts]
|
|
@@ -124,6 +131,11 @@ module = [
|
|
| 124 |
"numpy.*",
|
| 125 |
"pyarrow.*",
|
| 126 |
"pytest.*",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
# DeepISLES modules (only available in DeepISLES Docker image)
|
| 128 |
"src.isles22_ensemble",
|
| 129 |
]
|
|
|
|
| 31 |
"pydantic>=2.5.0",
|
| 32 |
"pydantic-settings>=2.1.0",
|
| 33 |
|
| 34 |
+
# Networking
|
| 35 |
+
"requests>=2.0.0",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
[project.optional-dependencies]
|
| 39 |
+
# API server (FastAPI backend for HF Spaces Docker deployment)
|
| 40 |
+
api = [
|
| 41 |
+
"fastapi>=0.115.0",
|
| 42 |
+
"uvicorn[standard]>=0.32.0",
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
# Legacy Gradio UI (being replaced by React frontend)
|
| 46 |
+
gradio = [
|
| 47 |
"gradio>=6.0.0,<7.0.0",
|
| 48 |
"matplotlib>=3.8.0",
|
|
|
|
|
|
|
| 49 |
"gradio_niivueviewer",
|
|
|
|
|
|
|
|
|
|
| 50 |
]
|
| 51 |
|
| 52 |
[project.scripts]
|
|
|
|
| 131 |
"numpy.*",
|
| 132 |
"pyarrow.*",
|
| 133 |
"pytest.*",
|
| 134 |
+
# FastAPI route decorators cause "untyped decorator" errors in strict mode
|
| 135 |
+
# See: https://github.com/tiangolo/fastapi/discussions/7463
|
| 136 |
+
"fastapi.*",
|
| 137 |
+
"starlette.*",
|
| 138 |
+
"uvicorn.*",
|
| 139 |
# DeepISLES modules (only available in DeepISLES Docker image)
|
| 140 |
"src.isles22_ensemble",
|
| 141 |
]
|
requirements.txt
CHANGED
|
@@ -17,12 +17,9 @@ numpy>=1.26.0,<2.0.0
|
|
| 17 |
pydantic>=2.5.0
|
| 18 |
pydantic-settings>=2.1.0
|
| 19 |
|
| 20 |
-
# UI - Gradio 6.x (latest stable as of Dec 2025)
|
| 21 |
-
gradio>=6.0.0,<7.0.0
|
| 22 |
-
matplotlib>=3.8.0
|
| 23 |
-
|
| 24 |
# Networking
|
| 25 |
requests>=2.0.0
|
| 26 |
|
| 27 |
-
#
|
| 28 |
-
|
|
|
|
|
|
| 17 |
pydantic>=2.5.0
|
| 18 |
pydantic-settings>=2.1.0
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
# Networking
|
| 21 |
requests>=2.0.0
|
| 22 |
|
| 23 |
+
# API server (FastAPI backend)
|
| 24 |
+
fastapi>=0.115.0
|
| 25 |
+
uvicorn[standard]>=0.32.0
|
src/stroke_deepisles_demo/api/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI REST API for stroke segmentation."""
|
| 2 |
+
|
| 3 |
+
from stroke_deepisles_demo.api.main import app
|
| 4 |
+
|
| 5 |
+
__all__ = ["app"]
|
src/stroke_deepisles_demo/api/main.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application for stroke segmentation API."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI
|
| 8 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
+
from fastapi.staticfiles import StaticFiles
|
| 10 |
+
|
| 11 |
+
from stroke_deepisles_demo.api.routes import router
|
| 12 |
+
|
| 13 |
+
app = FastAPI(
|
| 14 |
+
title="Stroke Segmentation API",
|
| 15 |
+
description="DeepISLES stroke lesion segmentation",
|
| 16 |
+
version="1.0.0",
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# CORS configuration
|
| 20 |
+
FRONTEND_ORIGIN = os.environ.get("FRONTEND_ORIGIN", "")
|
| 21 |
+
CORS_ORIGINS = [
|
| 22 |
+
"http://localhost:5173", # Vite dev server
|
| 23 |
+
"http://localhost:3000", # Alternative local port
|
| 24 |
+
]
|
| 25 |
+
if FRONTEND_ORIGIN:
|
| 26 |
+
CORS_ORIGINS.append(FRONTEND_ORIGIN)
|
| 27 |
+
|
| 28 |
+
app.add_middleware(
|
| 29 |
+
CORSMiddleware,
|
| 30 |
+
allow_origins=CORS_ORIGINS,
|
| 31 |
+
allow_origin_regex=r"https://.*--stroke-viewer-frontend(--.*)?\.hf\.space",
|
| 32 |
+
allow_credentials=True,
|
| 33 |
+
allow_methods=["*"],
|
| 34 |
+
allow_headers=["*"],
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# API routes
|
| 38 |
+
app.include_router(router, prefix="/api")
|
| 39 |
+
|
| 40 |
+
# Static files for NIfTI results
|
| 41 |
+
# Create directory if it doesn't exist (ensures mount works on first run)
|
| 42 |
+
RESULTS_DIR = Path("/tmp/stroke-results")
|
| 43 |
+
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
app.mount("/files", StaticFiles(directory=str(RESULTS_DIR)), name="files")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@app.get("/")
|
| 48 |
+
async def root() -> dict[str, Any]:
|
| 49 |
+
"""Health check endpoint."""
|
| 50 |
+
return {"status": "healthy", "service": "stroke-segmentation-api"}
|
src/stroke_deepisles_demo/api/routes.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API route handlers."""
|
| 2 |
+
|
| 3 |
+
import contextlib
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 9 |
+
|
| 10 |
+
from stroke_deepisles_demo.api.schemas import CasesResponse, SegmentRequest, SegmentResponse
|
| 11 |
+
from stroke_deepisles_demo.data import list_case_ids
|
| 12 |
+
from stroke_deepisles_demo.metrics import compute_volume_ml
|
| 13 |
+
from stroke_deepisles_demo.pipeline import run_pipeline_on_case
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
# Base directory for results
|
| 18 |
+
RESULTS_BASE = Path("/tmp/stroke-results")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_backend_base_url(request: Request) -> str:
|
| 22 |
+
"""Get the backend's public URL for building absolute file URLs.
|
| 23 |
+
|
| 24 |
+
Priority:
|
| 25 |
+
1. BACKEND_PUBLIC_URL env var (for production HF Spaces)
|
| 26 |
+
2. Request's base URL (for local development)
|
| 27 |
+
"""
|
| 28 |
+
env_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
|
| 29 |
+
if env_url:
|
| 30 |
+
return env_url
|
| 31 |
+
return str(request.base_url).rstrip("/")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.get("/cases", response_model=CasesResponse)
|
| 35 |
+
def get_cases() -> CasesResponse:
|
| 36 |
+
"""List available cases from dataset.
|
| 37 |
+
|
| 38 |
+
Note: This is a sync def (not async) because list_case_ids() is synchronous.
|
| 39 |
+
FastAPI automatically runs sync endpoints in a threadpool to avoid blocking.
|
| 40 |
+
"""
|
| 41 |
+
try:
|
| 42 |
+
cases = list_case_ids()
|
| 43 |
+
return CasesResponse(cases=cases)
|
| 44 |
+
except HTTPException:
|
| 45 |
+
raise
|
| 46 |
+
except Exception as e:
|
| 47 |
+
raise HTTPException(status_code=500, detail=str(e)) from None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.post("/segment", response_model=SegmentResponse)
|
| 51 |
+
def run_segmentation(request: Request, body: SegmentRequest) -> SegmentResponse:
|
| 52 |
+
"""Run DeepISLES segmentation on a case.
|
| 53 |
+
|
| 54 |
+
Note: This is a sync def (not async) because run_pipeline_on_case() is synchronous
|
| 55 |
+
and CPU/GPU-bound. FastAPI automatically runs sync endpoints in a threadpool,
|
| 56 |
+
which prevents blocking the event loop during inference.
|
| 57 |
+
"""
|
| 58 |
+
try:
|
| 59 |
+
# Generate unique run ID to avoid conflicts
|
| 60 |
+
run_id = str(uuid.uuid4())[:8]
|
| 61 |
+
output_dir = RESULTS_BASE / run_id
|
| 62 |
+
|
| 63 |
+
result = run_pipeline_on_case(
|
| 64 |
+
body.case_id,
|
| 65 |
+
output_dir=output_dir,
|
| 66 |
+
fast=body.fast_mode,
|
| 67 |
+
compute_dice=True,
|
| 68 |
+
cleanup_staging=True,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Compute volume (may fail for edge cases)
|
| 72 |
+
volume_ml = None
|
| 73 |
+
with contextlib.suppress(Exception):
|
| 74 |
+
volume_ml = round(compute_volume_ml(result.prediction_mask, threshold=0.5), 2)
|
| 75 |
+
|
| 76 |
+
# Build absolute file URLs
|
| 77 |
+
backend_url = get_backend_base_url(request)
|
| 78 |
+
dwi_filename = result.input_files["dwi"].name
|
| 79 |
+
pred_filename = result.prediction_mask.name
|
| 80 |
+
|
| 81 |
+
file_path_prefix = f"/files/{run_id}/{result.case_id}"
|
| 82 |
+
|
| 83 |
+
return SegmentResponse(
|
| 84 |
+
caseId=result.case_id,
|
| 85 |
+
diceScore=result.dice_score,
|
| 86 |
+
volumeMl=volume_ml,
|
| 87 |
+
elapsedSeconds=round(result.elapsed_seconds, 2),
|
| 88 |
+
dwiUrl=f"{backend_url}{file_path_prefix}/{dwi_filename}",
|
| 89 |
+
predictionUrl=f"{backend_url}{file_path_prefix}/{pred_filename}",
|
| 90 |
+
)
|
| 91 |
+
except HTTPException:
|
| 92 |
+
raise
|
| 93 |
+
except Exception as e:
|
| 94 |
+
raise HTTPException(status_code=500, detail=str(e)) from None
|
src/stroke_deepisles_demo/api/schemas.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas for API requests and responses."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class CasesResponse(BaseModel):
|
| 7 |
+
"""Response for GET /api/cases."""
|
| 8 |
+
|
| 9 |
+
cases: list[str]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class SegmentRequest(BaseModel):
|
| 13 |
+
"""Request body for POST /api/segment."""
|
| 14 |
+
|
| 15 |
+
case_id: str
|
| 16 |
+
fast_mode: bool = True
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class SegmentResponse(BaseModel):
|
| 20 |
+
"""Response for POST /api/segment."""
|
| 21 |
+
|
| 22 |
+
caseId: str
|
| 23 |
+
diceScore: float | None
|
| 24 |
+
volumeMl: float | None
|
| 25 |
+
elapsedSeconds: float
|
| 26 |
+
dwiUrl: str
|
| 27 |
+
predictionUrl: str
|
tests/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Tests for REST API endpoints."""
|
tests/api/test_endpoints.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TDD tests for API endpoints.
|
| 2 |
+
|
| 3 |
+
RED-GREEN-REFACTOR: Tests written FIRST, before implementation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from unittest.mock import MagicMock, patch
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from fastapi.testclient import TestClient
|
| 10 |
+
|
| 11 |
+
from stroke_deepisles_demo.api import app
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@pytest.fixture
|
| 15 |
+
def client() -> TestClient:
|
| 16 |
+
"""Create test client for FastAPI app."""
|
| 17 |
+
return TestClient(app)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TestHealthCheck:
|
| 21 |
+
"""Tests for root health check endpoint."""
|
| 22 |
+
|
| 23 |
+
def test_root_returns_healthy_status(self, client: TestClient) -> None:
|
| 24 |
+
"""GET / returns healthy status."""
|
| 25 |
+
response = client.get("/")
|
| 26 |
+
|
| 27 |
+
assert response.status_code == 200
|
| 28 |
+
data = response.json()
|
| 29 |
+
assert data["status"] == "healthy"
|
| 30 |
+
assert "service" in data
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TestGetCases:
|
| 34 |
+
"""Tests for GET /api/cases endpoint."""
|
| 35 |
+
|
| 36 |
+
def test_returns_list_of_case_ids(self, client: TestClient) -> None:
|
| 37 |
+
"""GET /api/cases returns a list of case IDs."""
|
| 38 |
+
with patch("stroke_deepisles_demo.api.routes.list_case_ids") as mock_list:
|
| 39 |
+
mock_list.return_value = ["sub-stroke0001", "sub-stroke0002", "sub-stroke0003"]
|
| 40 |
+
|
| 41 |
+
response = client.get("/api/cases")
|
| 42 |
+
|
| 43 |
+
assert response.status_code == 200
|
| 44 |
+
data = response.json()
|
| 45 |
+
assert "cases" in data
|
| 46 |
+
assert data["cases"] == ["sub-stroke0001", "sub-stroke0002", "sub-stroke0003"]
|
| 47 |
+
|
| 48 |
+
def test_returns_empty_list_when_no_cases(self, client: TestClient) -> None:
|
| 49 |
+
"""GET /api/cases returns empty list when no cases available."""
|
| 50 |
+
with patch("stroke_deepisles_demo.api.routes.list_case_ids") as mock_list:
|
| 51 |
+
mock_list.return_value = []
|
| 52 |
+
|
| 53 |
+
response = client.get("/api/cases")
|
| 54 |
+
|
| 55 |
+
assert response.status_code == 200
|
| 56 |
+
assert response.json()["cases"] == []
|
| 57 |
+
|
| 58 |
+
def test_returns_500_on_data_error(self, client: TestClient) -> None:
|
| 59 |
+
"""GET /api/cases returns 500 when data layer raises exception."""
|
| 60 |
+
with patch("stroke_deepisles_demo.api.routes.list_case_ids") as mock_list:
|
| 61 |
+
mock_list.side_effect = RuntimeError("Dataset not found")
|
| 62 |
+
|
| 63 |
+
response = client.get("/api/cases")
|
| 64 |
+
|
| 65 |
+
assert response.status_code == 500
|
| 66 |
+
assert "Dataset not found" in response.json()["detail"]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class TestPostSegment:
|
| 70 |
+
"""Tests for POST /api/segment endpoint."""
|
| 71 |
+
|
| 72 |
+
def test_runs_segmentation_and_returns_result(self, client: TestClient) -> None:
|
| 73 |
+
"""POST /api/segment runs pipeline and returns metrics + URLs."""
|
| 74 |
+
mock_result = MagicMock()
|
| 75 |
+
mock_result.case_id = "sub-stroke0001"
|
| 76 |
+
mock_result.dice_score = 0.847
|
| 77 |
+
mock_result.elapsed_seconds = 12.5
|
| 78 |
+
mock_result.prediction_mask.name = "prediction.nii.gz"
|
| 79 |
+
mock_result.input_files = {"dwi": MagicMock(name="dwi.nii.gz")}
|
| 80 |
+
mock_result.input_files["dwi"].name = "dwi.nii.gz"
|
| 81 |
+
|
| 82 |
+
with (
|
| 83 |
+
patch("stroke_deepisles_demo.api.routes.run_pipeline_on_case") as mock_pipeline,
|
| 84 |
+
patch("stroke_deepisles_demo.api.routes.compute_volume_ml") as mock_volume,
|
| 85 |
+
):
|
| 86 |
+
mock_pipeline.return_value = mock_result
|
| 87 |
+
mock_volume.return_value = 15.32
|
| 88 |
+
|
| 89 |
+
response = client.post(
|
| 90 |
+
"/api/segment",
|
| 91 |
+
json={"case_id": "sub-stroke0001", "fast_mode": True},
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
assert response.status_code == 200
|
| 95 |
+
data = response.json()
|
| 96 |
+
assert data["caseId"] == "sub-stroke0001"
|
| 97 |
+
assert data["diceScore"] == 0.847
|
| 98 |
+
assert data["volumeMl"] == 15.32
|
| 99 |
+
assert data["elapsedSeconds"] == 12.5
|
| 100 |
+
assert "dwi.nii.gz" in data["dwiUrl"]
|
| 101 |
+
assert "prediction.nii.gz" in data["predictionUrl"]
|
| 102 |
+
|
| 103 |
+
def test_passes_fast_mode_to_pipeline(self, client: TestClient) -> None:
|
| 104 |
+
"""POST /api/segment passes fast_mode parameter to pipeline."""
|
| 105 |
+
mock_result = MagicMock()
|
| 106 |
+
mock_result.case_id = "sub-stroke0001"
|
| 107 |
+
mock_result.dice_score = None
|
| 108 |
+
mock_result.elapsed_seconds = 45.0
|
| 109 |
+
mock_result.prediction_mask.name = "pred.nii.gz"
|
| 110 |
+
mock_result.input_files = {"dwi": MagicMock()}
|
| 111 |
+
mock_result.input_files["dwi"].name = "dwi.nii.gz"
|
| 112 |
+
|
| 113 |
+
with (
|
| 114 |
+
patch("stroke_deepisles_demo.api.routes.run_pipeline_on_case") as mock_pipeline,
|
| 115 |
+
patch("stroke_deepisles_demo.api.routes.compute_volume_ml"),
|
| 116 |
+
):
|
| 117 |
+
mock_pipeline.return_value = mock_result
|
| 118 |
+
|
| 119 |
+
client.post(
|
| 120 |
+
"/api/segment",
|
| 121 |
+
json={"case_id": "sub-stroke0001", "fast_mode": False},
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
mock_pipeline.assert_called_once()
|
| 125 |
+
call_kwargs = mock_pipeline.call_args[1]
|
| 126 |
+
assert call_kwargs["fast"] is False
|
| 127 |
+
|
| 128 |
+
def test_returns_422_on_missing_case_id(self, client: TestClient) -> None:
|
| 129 |
+
"""POST /api/segment returns 422 when case_id is missing."""
|
| 130 |
+
response = client.post("/api/segment", json={})
|
| 131 |
+
|
| 132 |
+
assert response.status_code == 422
|
| 133 |
+
|
| 134 |
+
def test_returns_500_on_pipeline_error(self, client: TestClient) -> None:
|
| 135 |
+
"""POST /api/segment returns 500 when pipeline raises exception."""
|
| 136 |
+
with patch("stroke_deepisles_demo.api.routes.run_pipeline_on_case") as mock_pipeline:
|
| 137 |
+
mock_pipeline.side_effect = RuntimeError("GPU out of memory")
|
| 138 |
+
|
| 139 |
+
response = client.post(
|
| 140 |
+
"/api/segment",
|
| 141 |
+
json={"case_id": "sub-stroke0001"},
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
assert response.status_code == 500
|
| 145 |
+
assert "GPU out of memory" in response.json()["detail"]
|
uv.lock
CHANGED
|
@@ -950,6 +950,42 @@ wheels = [
|
|
| 950 |
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
|
| 951 |
]
|
| 952 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 953 |
[[package]]
|
| 954 |
name = "httpx"
|
| 955 |
version = "0.28.1"
|
|
@@ -2402,10 +2438,7 @@ version = "0.1.0"
|
|
| 2402 |
source = { editable = "." }
|
| 2403 |
dependencies = [
|
| 2404 |
{ name = "datasets" },
|
| 2405 |
-
{ name = "gradio" },
|
| 2406 |
-
{ name = "gradio-niivueviewer" },
|
| 2407 |
{ name = "huggingface-hub" },
|
| 2408 |
-
{ name = "matplotlib" },
|
| 2409 |
{ name = "nibabel" },
|
| 2410 |
{ name = "numpy" },
|
| 2411 |
{ name = "pydantic" },
|
|
@@ -2413,6 +2446,17 @@ dependencies = [
|
|
| 2413 |
{ name = "requests" },
|
| 2414 |
]
|
| 2415 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2416 |
[package.dev-dependencies]
|
| 2417 |
dev = [
|
| 2418 |
{ name = "mypy" },
|
|
@@ -2428,16 +2472,19 @@ dev = [
|
|
| 2428 |
[package.metadata]
|
| 2429 |
requires-dist = [
|
| 2430 |
{ name = "datasets", git = "https://github.com/CloseChoice/datasets.git?rev=c1c15aaa4f00f28f1916f3a896283494162eac49" },
|
| 2431 |
-
{ name = "
|
| 2432 |
-
{ name = "gradio
|
|
|
|
| 2433 |
{ name = "huggingface-hub", specifier = ">=0.25.0" },
|
| 2434 |
-
{ name = "matplotlib", specifier = ">=3.8.0" },
|
| 2435 |
{ name = "nibabel", specifier = ">=5.2.0" },
|
| 2436 |
{ name = "numpy", specifier = ">=1.26.0,<2.0.0" },
|
| 2437 |
{ name = "pydantic", specifier = ">=2.5.0" },
|
| 2438 |
{ name = "pydantic-settings", specifier = ">=2.1.0" },
|
| 2439 |
{ name = "requests", specifier = ">=2.0.0" },
|
|
|
|
| 2440 |
]
|
|
|
|
| 2441 |
|
| 2442 |
[package.metadata.requires-dev]
|
| 2443 |
dev = [
|
|
@@ -2613,6 +2660,55 @@ wheels = [
|
|
| 2613 |
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109 },
|
| 2614 |
]
|
| 2615 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2616 |
[[package]]
|
| 2617 |
name = "virtualenv"
|
| 2618 |
version = "20.35.4"
|
|
@@ -2627,6 +2723,135 @@ wheels = [
|
|
| 2627 |
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095 },
|
| 2628 |
]
|
| 2629 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2630 |
[[package]]
|
| 2631 |
name = "xxhash"
|
| 2632 |
version = "3.6.0"
|
|
|
|
| 950 |
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
|
| 951 |
]
|
| 952 |
|
| 953 |
+
[[package]]
|
| 954 |
+
name = "httptools"
|
| 955 |
+
version = "0.7.1"
|
| 956 |
+
source = { registry = "https://pypi.org/simple" }
|
| 957 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 }
|
| 958 |
+
wheels = [
|
| 959 |
+
{ url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521 },
|
| 960 |
+
{ url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375 },
|
| 961 |
+
{ url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621 },
|
| 962 |
+
{ url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954 },
|
| 963 |
+
{ url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175 },
|
| 964 |
+
{ url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310 },
|
| 965 |
+
{ url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875 },
|
| 966 |
+
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280 },
|
| 967 |
+
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004 },
|
| 968 |
+
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655 },
|
| 969 |
+
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440 },
|
| 970 |
+
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186 },
|
| 971 |
+
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192 },
|
| 972 |
+
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694 },
|
| 973 |
+
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889 },
|
| 974 |
+
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180 },
|
| 975 |
+
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596 },
|
| 976 |
+
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268 },
|
| 977 |
+
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517 },
|
| 978 |
+
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337 },
|
| 979 |
+
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743 },
|
| 980 |
+
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619 },
|
| 981 |
+
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714 },
|
| 982 |
+
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909 },
|
| 983 |
+
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831 },
|
| 984 |
+
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631 },
|
| 985 |
+
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910 },
|
| 986 |
+
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205 },
|
| 987 |
+
]
|
| 988 |
+
|
| 989 |
[[package]]
|
| 990 |
name = "httpx"
|
| 991 |
version = "0.28.1"
|
|
|
|
| 2438 |
source = { editable = "." }
|
| 2439 |
dependencies = [
|
| 2440 |
{ name = "datasets" },
|
|
|
|
|
|
|
| 2441 |
{ name = "huggingface-hub" },
|
|
|
|
| 2442 |
{ name = "nibabel" },
|
| 2443 |
{ name = "numpy" },
|
| 2444 |
{ name = "pydantic" },
|
|
|
|
| 2446 |
{ name = "requests" },
|
| 2447 |
]
|
| 2448 |
|
| 2449 |
+
[package.optional-dependencies]
|
| 2450 |
+
api = [
|
| 2451 |
+
{ name = "fastapi" },
|
| 2452 |
+
{ name = "uvicorn", extra = ["standard"] },
|
| 2453 |
+
]
|
| 2454 |
+
gradio = [
|
| 2455 |
+
{ name = "gradio" },
|
| 2456 |
+
{ name = "gradio-niivueviewer" },
|
| 2457 |
+
{ name = "matplotlib" },
|
| 2458 |
+
]
|
| 2459 |
+
|
| 2460 |
[package.dev-dependencies]
|
| 2461 |
dev = [
|
| 2462 |
{ name = "mypy" },
|
|
|
|
| 2472 |
[package.metadata]
|
| 2473 |
requires-dist = [
|
| 2474 |
{ name = "datasets", git = "https://github.com/CloseChoice/datasets.git?rev=c1c15aaa4f00f28f1916f3a896283494162eac49" },
|
| 2475 |
+
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
|
| 2476 |
+
{ name = "gradio", marker = "extra == 'gradio'", specifier = ">=6.0.0,<7.0.0" },
|
| 2477 |
+
{ name = "gradio-niivueviewer", marker = "extra == 'gradio'", editable = "packages/niivueviewer" },
|
| 2478 |
{ name = "huggingface-hub", specifier = ">=0.25.0" },
|
| 2479 |
+
{ name = "matplotlib", marker = "extra == 'gradio'", specifier = ">=3.8.0" },
|
| 2480 |
{ name = "nibabel", specifier = ">=5.2.0" },
|
| 2481 |
{ name = "numpy", specifier = ">=1.26.0,<2.0.0" },
|
| 2482 |
{ name = "pydantic", specifier = ">=2.5.0" },
|
| 2483 |
{ name = "pydantic-settings", specifier = ">=2.1.0" },
|
| 2484 |
{ name = "requests", specifier = ">=2.0.0" },
|
| 2485 |
+
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'api'", specifier = ">=0.32.0" },
|
| 2486 |
]
|
| 2487 |
+
provides-extras = ["api", "gradio"]
|
| 2488 |
|
| 2489 |
[package.metadata.requires-dev]
|
| 2490 |
dev = [
|
|
|
|
| 2660 |
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109 },
|
| 2661 |
]
|
| 2662 |
|
| 2663 |
+
[package.optional-dependencies]
|
| 2664 |
+
standard = [
|
| 2665 |
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
| 2666 |
+
{ name = "httptools" },
|
| 2667 |
+
{ name = "python-dotenv" },
|
| 2668 |
+
{ name = "pyyaml" },
|
| 2669 |
+
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
| 2670 |
+
{ name = "watchfiles" },
|
| 2671 |
+
{ name = "websockets" },
|
| 2672 |
+
]
|
| 2673 |
+
|
| 2674 |
+
[[package]]
|
| 2675 |
+
name = "uvloop"
|
| 2676 |
+
version = "0.22.1"
|
| 2677 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2678 |
+
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 }
|
| 2679 |
+
wheels = [
|
| 2680 |
+
{ 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 },
|
| 2681 |
+
{ 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 },
|
| 2682 |
+
{ 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 },
|
| 2683 |
+
{ 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 },
|
| 2684 |
+
{ 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 },
|
| 2685 |
+
{ 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 },
|
| 2686 |
+
{ 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 },
|
| 2687 |
+
{ 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 },
|
| 2688 |
+
{ 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 },
|
| 2689 |
+
{ 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 },
|
| 2690 |
+
{ 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 },
|
| 2691 |
+
{ 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 },
|
| 2692 |
+
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 },
|
| 2693 |
+
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 },
|
| 2694 |
+
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 },
|
| 2695 |
+
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 },
|
| 2696 |
+
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 },
|
| 2697 |
+
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 },
|
| 2698 |
+
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 },
|
| 2699 |
+
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 },
|
| 2700 |
+
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 },
|
| 2701 |
+
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 },
|
| 2702 |
+
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 },
|
| 2703 |
+
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 },
|
| 2704 |
+
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 },
|
| 2705 |
+
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 },
|
| 2706 |
+
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 },
|
| 2707 |
+
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 },
|
| 2708 |
+
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 },
|
| 2709 |
+
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 },
|
| 2710 |
+
]
|
| 2711 |
+
|
| 2712 |
[[package]]
|
| 2713 |
name = "virtualenv"
|
| 2714 |
version = "20.35.4"
|
|
|
|
| 2723 |
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095 },
|
| 2724 |
]
|
| 2725 |
|
| 2726 |
+
[[package]]
|
| 2727 |
+
name = "watchfiles"
|
| 2728 |
+
version = "1.1.1"
|
| 2729 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2730 |
+
dependencies = [
|
| 2731 |
+
{ name = "anyio" },
|
| 2732 |
+
]
|
| 2733 |
+
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 }
|
| 2734 |
+
wheels = [
|
| 2735 |
+
{ url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529 },
|
| 2736 |
+
{ url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384 },
|
| 2737 |
+
{ url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789 },
|
| 2738 |
+
{ url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521 },
|
| 2739 |
+
{ url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722 },
|
| 2740 |
+
{ url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088 },
|
| 2741 |
+
{ url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923 },
|
| 2742 |
+
{ url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080 },
|
| 2743 |
+
{ url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432 },
|
| 2744 |
+
{ url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046 },
|
| 2745 |
+
{ url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473 },
|
| 2746 |
+
{ url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598 },
|
| 2747 |
+
{ url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210 },
|
| 2748 |
+
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745 },
|
| 2749 |
+
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769 },
|
| 2750 |
+
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374 },
|
| 2751 |
+
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485 },
|
| 2752 |
+
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813 },
|
| 2753 |
+
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816 },
|
| 2754 |
+
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186 },
|
| 2755 |
+
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812 },
|
| 2756 |
+
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196 },
|
| 2757 |
+
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657 },
|
| 2758 |
+
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042 },
|
| 2759 |
+
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410 },
|
| 2760 |
+
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209 },
|
| 2761 |
+
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321 },
|
| 2762 |
+
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783 },
|
| 2763 |
+
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279 },
|
| 2764 |
+
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405 },
|
| 2765 |
+
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976 },
|
| 2766 |
+
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506 },
|
| 2767 |
+
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936 },
|
| 2768 |
+
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147 },
|
| 2769 |
+
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007 },
|
| 2770 |
+
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280 },
|
| 2771 |
+
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056 },
|
| 2772 |
+
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162 },
|
| 2773 |
+
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909 },
|
| 2774 |
+
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389 },
|
| 2775 |
+
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964 },
|
| 2776 |
+
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114 },
|
| 2777 |
+
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264 },
|
| 2778 |
+
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877 },
|
| 2779 |
+
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176 },
|
| 2780 |
+
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577 },
|
| 2781 |
+
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425 },
|
| 2782 |
+
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826 },
|
| 2783 |
+
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208 },
|
| 2784 |
+
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315 },
|
| 2785 |
+
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869 },
|
| 2786 |
+
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919 },
|
| 2787 |
+
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845 },
|
| 2788 |
+
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027 },
|
| 2789 |
+
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615 },
|
| 2790 |
+
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836 },
|
| 2791 |
+
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099 },
|
| 2792 |
+
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626 },
|
| 2793 |
+
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519 },
|
| 2794 |
+
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078 },
|
| 2795 |
+
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664 },
|
| 2796 |
+
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154 },
|
| 2797 |
+
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820 },
|
| 2798 |
+
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510 },
|
| 2799 |
+
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408 },
|
| 2800 |
+
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968 },
|
| 2801 |
+
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096 },
|
| 2802 |
+
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040 },
|
| 2803 |
+
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847 },
|
| 2804 |
+
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 },
|
| 2805 |
+
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 },
|
| 2806 |
+
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 },
|
| 2807 |
+
{ url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250 },
|
| 2808 |
+
{ url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117 },
|
| 2809 |
+
{ url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493 },
|
| 2810 |
+
{ url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546 },
|
| 2811 |
+
]
|
| 2812 |
+
|
| 2813 |
+
[[package]]
|
| 2814 |
+
name = "websockets"
|
| 2815 |
+
version = "15.0.1"
|
| 2816 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2817 |
+
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 }
|
| 2818 |
+
wheels = [
|
| 2819 |
+
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423 },
|
| 2820 |
+
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082 },
|
| 2821 |
+
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330 },
|
| 2822 |
+
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878 },
|
| 2823 |
+
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883 },
|
| 2824 |
+
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252 },
|
| 2825 |
+
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521 },
|
| 2826 |
+
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958 },
|
| 2827 |
+
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918 },
|
| 2828 |
+
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388 },
|
| 2829 |
+
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828 },
|
| 2830 |
+
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 },
|
| 2831 |
+
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 },
|
| 2832 |
+
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 },
|
| 2833 |
+
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 },
|
| 2834 |
+
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 },
|
| 2835 |
+
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 },
|
| 2836 |
+
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 },
|
| 2837 |
+
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 },
|
| 2838 |
+
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 },
|
| 2839 |
+
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 },
|
| 2840 |
+
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 },
|
| 2841 |
+
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 },
|
| 2842 |
+
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 },
|
| 2843 |
+
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 },
|
| 2844 |
+
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 },
|
| 2845 |
+
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 },
|
| 2846 |
+
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 },
|
| 2847 |
+
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 },
|
| 2848 |
+
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 },
|
| 2849 |
+
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 },
|
| 2850 |
+
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 },
|
| 2851 |
+
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 },
|
| 2852 |
+
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 },
|
| 2853 |
+
]
|
| 2854 |
+
|
| 2855 |
[[package]]
|
| 2856 |
name = "xxhash"
|
| 2857 |
version = "3.6.0"
|