diff --git a/.cspell/general-technical.txt b/.cspell/general-technical.txt index e77448b6..fd621943 100644 --- a/.cspell/general-technical.txt +++ b/.cspell/general-technical.txt @@ -1089,6 +1089,7 @@ redhat redirections redisenterprise redtiger +redoc reengineer reengineered reengineering @@ -1217,6 +1218,7 @@ slas sles sloc slos +slowapi slsa smartctl smes diff --git a/data-management/viewer/README.md b/data-management/viewer/README.md index b0a9395e..b3612c38 100644 --- a/data-management/viewer/README.md +++ b/data-management/viewer/README.md @@ -25,7 +25,7 @@ uv venv --python 3.11 source .venv/bin/activate # Install dependencies (include 'azure' extra for blob storage support) -uv pip install -e ".[dev,analysis,export,azure]" +uv pip install -e ".[dev,export,azure]" ``` ### Frontend Setup diff --git a/data-management/viewer/backend/.env.example b/data-management/viewer/backend/.env.example index 192ca037..658edfb9 100644 --- a/data-management/viewer/backend/.env.example +++ b/data-management/viewer/backend/.env.example @@ -57,6 +57,18 @@ HMI_DATA_PATH=../../../datasets # Port for the backend API server. # BACKEND_PORT=8000 +# ───────────────────────────────────────────────────────────────────────────── +# Security middleware +# ───────────────────────────────────────────────────────────────────────────── + +# Maximum request body size in bytes. +# Requests exceeding this limit receive HTTP 413. Default: 10 MB. +# MAX_REQUEST_BODY_BYTES=10485760 + +# Rate limits for detection endpoints (slowapi format, e.g. "10/minute", "100/hour"). +# RATE_LIMIT_DETECT=10/minute +# RATE_LIMIT_DETECTIONS=120/minute + # ───────────────────────────────────────────────────────────────────────────── # CORS (set for container / cloud deployments) # ───────────────────────────────────────────────────────────────────────────── diff --git a/data-management/viewer/backend/pyproject.toml b/data-management/viewer/backend/pyproject.toml index 4bfc7986..e39cac56 100644 --- a/data-management/viewer/backend/pyproject.toml +++ b/data-management/viewer/backend/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "pydantic==2.12.5", "python-multipart==0.0.26", "python-dotenv==1.2.2", + "slowapi==0.1.9", "aiofiles==25.1.0", "numpy==2.4.4", "pyarrow==23.0.1", diff --git a/data-management/viewer/backend/src/api/main.py b/data-management/viewer/backend/src/api/main.py index 99ea4796..58a9211e 100644 --- a/data-management/viewer/backend/src/api/main.py +++ b/data-management/viewer/backend/src/api/main.py @@ -8,11 +8,16 @@ from dotenv import load_dotenv from fastapi import Depends, FastAPI +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded from .auth import require_auth from .csrf import CSRF_COOKIE_NAME, generate_csrf_token +from .middleware import ContentSizeLimitMiddleware, SecurityHeadersMiddleware +from .rate_limiter import limiter from .routers import analysis, annotations, datasets, detection, export, joint_config, labels from .routes import ai_analysis @@ -81,13 +86,37 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: }, ) -# Configure CORS — origins are read from CORS_ORIGINS env var (comma-separated) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request, exc: Exception) -> JSONResponse: + """Log full traceback server-side, return generic error to client.""" + logger.exception("Unhandled exception on %s %s", request.method, request.url.path) + return JSONResponse(status_code=500, content={"detail": "Internal server error"}) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc: RequestValidationError) -> JSONResponse: + """Return validation errors without internal paths.""" + errors = [{"loc": error.get("loc"), "msg": error.get("msg"), "type": error.get("type")} for error in exc.errors()] + return JSONResponse(status_code=422, content={"detail": errors}) + + +# Middleware stack (last added = outermost = first to execute) +# Order: SecurityHeaders → ContentSizeLimit → CORS → FastAPI App +app.add_middleware(SecurityHeadersMiddleware) +app.add_middleware( + ContentSizeLimitMiddleware, + max_content_length=int(os.environ.get("MAX_REQUEST_BODY_BYTES", str(10 * 1024 * 1024))), +) app.add_middleware( CORSMiddleware, allow_origins=_config.cors_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "X-API-Key", "X-Request-ID"], ) # All /api/* routes require authentication (health and csrf-token are on app directly) @@ -105,8 +134,28 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: @app.get("/health") async def health_check(): - """Health check endpoint.""" - return {"status": "healthy"} + """Health check verifying API and storage connectivity.""" + checks: dict[str, str] = {"api": "healthy"} + + try: + from .services.dataset_service import get_dataset_service + + service = get_dataset_service() + if hasattr(service, "base_path"): + from pathlib import Path as _Path + + if _Path(service.base_path).exists(): + checks["storage"] = "healthy" + else: + checks["storage"] = "unhealthy" + else: + checks["storage"] = "healthy" + except Exception: + checks["storage"] = "unhealthy" + + overall = "healthy" if all(v == "healthy" for v in checks.values()) else "degraded" + status_code = 200 if overall == "healthy" else 503 + return JSONResponse(content={"status": overall, "checks": checks}, status_code=status_code) @app.get("/api/csrf-token", tags=["auth"]) @@ -130,5 +179,6 @@ async def get_csrf_token() -> JSONResponse: httponly=False, samesite="strict", secure=secure, + path="/", ) return response diff --git a/data-management/viewer/backend/src/api/middleware.py b/data-management/viewer/backend/src/api/middleware.py new file mode 100644 index 00000000..c0d0d046 --- /dev/null +++ b/data-management/viewer/backend/src/api/middleware.py @@ -0,0 +1,114 @@ +"""OWASP security headers and request body size enforcement middleware.""" + +from __future__ import annotations + +from typing import ClassVar + +from starlette.responses import JSONResponse as _StarletteJSONResponse + + +class SecurityHeadersMiddleware: + """Inject OWASP security headers on all HTTP responses. + + CSP is only applied to non-API paths to avoid interfering with + frontend dev servers that proxy API responses and inherit headers. + """ + + HEADERS: ClassVar[list[tuple[bytes, bytes]]] = [ + (b"x-content-type-options", b"nosniff"), + (b"x-frame-options", b"DENY"), + (b"referrer-policy", b"strict-origin-when-cross-origin"), + (b"permissions-policy", b"geolocation=(), microphone=(), camera=()"), + (b"cross-origin-opener-policy", b"same-origin"), + ] + + CSP_HEADER: ClassVar[tuple[bytes, bytes]] = ( + b"content-security-policy", + b"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + b"img-src 'self' data: blob:; connect-src 'self'; font-src 'self'; object-src 'none'", + ) + + _SKIP_PATHS: ClassVar[set[str]] = {"/docs", "/redoc", "/openapi.json"} + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path = scope.get("path", "") + if path in self._SKIP_PATHS: + await self.app(scope, receive, send) + return + + is_api = path.startswith("/api") or path == "/health" + extra_headers = self.HEADERS if is_api else [*self.HEADERS, self.CSP_HEADER] + + async def send_with_headers(message): + if message["type"] == "http.response.start": + headers = list(message.get("headers", [])) + headers.extend(extra_headers) + message = {**message, "headers": headers} + await send(message) + + await self.app(scope, receive, send_with_headers) + + +class ContentSizeLimitMiddleware: + """Reject requests exceeding the configured body size limit. + + Checks Content-Length header upfront and tracks actual bytes + received to catch chunked transfer-encoding requests. + """ + + def __init__(self, app, max_content_length: int = 10 * 1024 * 1024): + self.app = app + self.max_content_length = max_content_length + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + headers = dict(scope.get("headers", [])) + content_length = headers.get(b"content-length") + + if content_length is not None: + try: + if int(content_length) > self.max_content_length: + response = _StarletteJSONResponse( + {"detail": "Request body too large"}, + status_code=413, + ) + await response(scope, receive, send) + return + except ValueError: + pass # Malformed Content-Length header; fall through to streaming byte check + + bytes_received = 0 + limit = self.max_content_length + + async def receive_with_limit(): + nonlocal bytes_received + message = await receive() + if message.get("type") == "http.request": + body = message.get("body", b"") + bytes_received += len(body) + if bytes_received > limit: + raise _BodyTooLargeError + return message + + try: + await self.app(scope, receive_with_limit, send) + except _BodyTooLargeError: + response = _StarletteJSONResponse( + {"detail": "Request body too large"}, + status_code=413, + ) + await response(scope, receive, send) + + +class _BodyTooLargeError(Exception): + """Internal signal for body size limit exceeded.""" diff --git a/data-management/viewer/backend/src/api/rate_limiter.py b/data-management/viewer/backend/src/api/rate_limiter.py new file mode 100644 index 00000000..db4aeb0f --- /dev/null +++ b/data-management/viewer/backend/src/api/rate_limiter.py @@ -0,0 +1,8 @@ +"""Shared rate limiter instance for the entire API.""" + +from __future__ import annotations + +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/data-management/viewer/backend/src/api/routers/detection.py b/data-management/viewer/backend/src/api/routers/detection.py index 2bba7548..6ddd76ea 100644 --- a/data-management/viewer/backend/src/api/routers/detection.py +++ b/data-management/viewer/backend/src/api/routers/detection.py @@ -6,12 +6,13 @@ """ import logging -import sys +import os -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from ..csrf import require_csrf_token from ..models.detection import DetectionRequest, EpisodeDetectionSummary +from ..rate_limiter import limiter from ..services.dataset_service import DatasetService, get_dataset_service from ..services.detection_service import DetectionService, get_detection_service from ..validation import SAFE_DATASET_ID_PATTERN, path_int_param, path_string_param @@ -20,15 +21,26 @@ logger = logging.getLogger(__name__) +def _sanitize_for_log(value: object) -> str: + """Sanitize user-controlled values before writing to logs to prevent log-forging via CR/LF injection.""" + return str(value).replace("\r", "\\r").replace("\n", "\\n") + + +RATE_LIMIT_DETECT = os.environ.get("RATE_LIMIT_DETECT", "10/minute") +RATE_LIMIT_DETECTIONS = os.environ.get("RATE_LIMIT_DETECTIONS", "120/minute") + + @router.post( "/{dataset_id}/episodes/{episode_idx}/detect", response_model=EpisodeDetectionSummary, dependencies=[Depends(require_csrf_token)], ) +@limiter.limit(RATE_LIMIT_DETECT) async def run_detection( + request: Request, episode_idx: int = Depends(path_int_param("episode_idx", ge=0, description="Episode index")), dataset_id: str = Depends(path_string_param("dataset_id", pattern=SAFE_DATASET_ID_PATTERN, label="dataset_id")), - request: DetectionRequest = DetectionRequest(), + request_body: DetectionRequest = DetectionRequest(), detection_service: DetectionService = Depends(get_detection_service), dataset_service: DatasetService = Depends(get_dataset_service), ) -> EpisodeDetectionSummary: @@ -39,30 +51,25 @@ async def run_detection( returns detection results with bounding boxes and class labels. Results are cached for subsequent retrieval. """ - print(f"\n{'=' * 60}", file=sys.stderr, flush=True) - print( - f"[API] POST /detect called: dataset={dataset_id}, episode={episode_idx}", - file=sys.stderr, - flush=True, + logger.info( + "POST /detect: dataset=%s, episode=%d, model=%s, confidence=%s", + _sanitize_for_log(dataset_id), + int(episode_idx), + _sanitize_for_log(request_body.model), + float(request_body.confidence), ) - print( - f"[API] Request: model={request.model}, confidence={request.confidence}", - file=sys.stderr, - flush=True, - ) - print(f"{'=' * 60}", file=sys.stderr, flush=True) # Validate episode exists episode = await dataset_service.get_episode(dataset_id, episode_idx) if episode is None: - print("[API] ERROR: Episode not found", file=sys.stderr, flush=True) + logger.warning("Episode %d not found in dataset %s", int(episode_idx), _sanitize_for_log(dataset_id)) raise HTTPException( status_code=404, detail=f"Episode {episode_idx} not found in dataset '{dataset_id}'", ) total_frames = episode.meta.length - print(f"[API] Episode has {total_frames} frames", file=sys.stderr, flush=True) + logger.info("Episode has %d frames", total_frames) # Create frame image getter async def get_frame_image(frame_idx: int) -> bytes | None: @@ -72,7 +79,7 @@ async def get_frame_image(frame_idx: int) -> bytes | None: summary = await detection_service.detect_episode( dataset_id, episode_idx, - request, + request_body, get_frame_image, total_frames, ) @@ -82,11 +89,11 @@ async def get_frame_image(frame_idx: int) -> bytes | None: status_code=503, detail="YOLO dependencies not installed. Run: uv sync --extra yolo", ) - except Exception as e: + except Exception: logger.exception("Detection failed") raise HTTPException( status_code=500, - detail=f"Detection failed: {e!s}", + detail="Detection failed", ) @@ -94,7 +101,9 @@ async def get_frame_image(frame_idx: int) -> bytes | None: "/{dataset_id}/episodes/{episode_idx}/detections", response_model=EpisodeDetectionSummary | None, ) +@limiter.limit(RATE_LIMIT_DETECTIONS) async def get_detections( + request: Request, episode_idx: int = Depends(path_int_param("episode_idx", ge=0, description="Episode index")), dataset_id: str = Depends(path_string_param("dataset_id", pattern=SAFE_DATASET_ID_PATTERN, label="dataset_id")), detection_service: DetectionService = Depends(get_detection_service), @@ -107,8 +116,13 @@ async def get_detections( return detection_service.get_cached(dataset_id, episode_idx) -@router.delete("/{dataset_id}/episodes/{episode_idx}/detections") +@router.delete( + "/{dataset_id}/episodes/{episode_idx}/detections", + dependencies=[Depends(require_csrf_token)], +) +@limiter.limit(RATE_LIMIT_DETECTIONS) async def clear_detections( + request: Request, episode_idx: int = Depends(path_int_param("episode_idx", ge=0, description="Episode index")), dataset_id: str = Depends(path_string_param("dataset_id", pattern=SAFE_DATASET_ID_PATTERN, label="dataset_id")), detection_service: DetectionService = Depends(get_detection_service), diff --git a/data-management/viewer/backend/tests/test_api_endpoints.py b/data-management/viewer/backend/tests/test_api_endpoints.py index c4002b6d..947d0b0d 100644 --- a/data-management/viewer/backend/tests/test_api_endpoints.py +++ b/data-management/viewer/backend/tests/test_api_endpoints.py @@ -16,7 +16,10 @@ class TestHealthEndpoint: def test_health(self, client): resp = client.get("/health") assert resp.status_code == 200 - assert resp.json() == {"status": "healthy"} + data = resp.json() + assert data["status"] == "healthy" + assert data["checks"]["api"] == "healthy" + assert data["checks"]["storage"] == "healthy" class TestListDatasets: diff --git a/data-management/viewer/backend/tests/test_health.py b/data-management/viewer/backend/tests/test_health.py index eb6667e3..4e176ea7 100644 --- a/data-management/viewer/backend/tests/test_health.py +++ b/data-management/viewer/backend/tests/test_health.py @@ -1,8 +1,17 @@ """Health check tests.""" -def test_health_check(client): - """Test health endpoint.""" - response = client.get("/health") - assert response.status_code == 200 - assert response.json() == {"status": "healthy"} +class TestHealthCheck: + def test_health_check_returns_200(self, client): + """Test health endpoint returns structured response with checks.""" + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert data["checks"]["api"] == "healthy" + assert data["checks"]["storage"] == "healthy" + + def test_health_check_includes_storage_probe(self, client): + """Verify storage check is present in health response.""" + response = client.get("/health") + assert "storage" in response.json()["checks"] diff --git a/data-management/viewer/backend/tests/test_rate_limiter.py b/data-management/viewer/backend/tests/test_rate_limiter.py new file mode 100644 index 00000000..fefda0a2 --- /dev/null +++ b/data-management/viewer/backend/tests/test_rate_limiter.py @@ -0,0 +1,31 @@ +"""Tests for the shared rate limiter module.""" + +from __future__ import annotations + + +class TestSharedRateLimiter: + """Verify a single Limiter instance is shared across main and detection.""" + + def test_given_shared_module_when_imported_from_main_and_detection_then_same_instance(self): + # Act + from src.api.main import limiter as main_limiter + from src.api.routers.detection import limiter as detection_limiter + + # Assert + assert main_limiter is detection_limiter + + def test_given_rate_limiter_module_when_imported_then_has_limiter_attribute(self): + # Act + from src.api.rate_limiter import limiter + + # Assert + assert limiter is not None + + def test_given_rate_limiter_module_when_imported_then_uses_get_remote_address(self): + # Act + from slowapi.util import get_remote_address + + from src.api.rate_limiter import limiter + + # Assert + assert limiter._key_func is get_remote_address diff --git a/data-management/viewer/backend/tests/test_security_middleware.py b/data-management/viewer/backend/tests/test_security_middleware.py new file mode 100644 index 00000000..e579687f --- /dev/null +++ b/data-management/viewer/backend/tests/test_security_middleware.py @@ -0,0 +1,645 @@ +"""Tests for security middleware, exception handlers, and hardened endpoints.""" + +from __future__ import annotations + +import os + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def security_client(tmp_path): + """Test client with a valid HMI_DATA_PATH for security tests.""" + import src.api.config as config_mod + import src.api.services.annotation_service as ann_mod + import src.api.services.dataset_service as ds_mod + + os.environ["HMI_DATA_PATH"] = str(tmp_path) + config_mod._app_config = None + ds_mod._dataset_service = None + ann_mod._annotation_service = None + + from src.api.main import app + + with TestClient(app) as c: + yield c + + config_mod._app_config = None + ds_mod._dataset_service = None + ann_mod._annotation_service = None + + +# ============================================================================ +# Security Headers Middleware +# ============================================================================ + + +class TestSecurityHeaders: + """Verify OWASP security headers are present on every response.""" + + def test_x_content_type_options(self, security_client): + resp = security_client.get("/health") + assert resp.headers["x-content-type-options"] == "nosniff" + + def test_x_frame_options(self, security_client): + resp = security_client.get("/health") + assert resp.headers["x-frame-options"] == "DENY" + + def test_referrer_policy(self, security_client): + resp = security_client.get("/health") + assert resp.headers["referrer-policy"] == "strict-origin-when-cross-origin" + + def test_permissions_policy(self, security_client): + resp = security_client.get("/health") + assert resp.headers["permissions-policy"] == "geolocation=(), microphone=(), camera=()" + + def test_cross_origin_opener_policy(self, security_client): + resp = security_client.get("/health") + assert resp.headers["cross-origin-opener-policy"] == "same-origin" + + def test_csp_not_on_api_responses(self, security_client): + """CSP is only on non-API paths to avoid breaking proxied frontends.""" + resp = security_client.get("/health") + assert "content-security-policy" not in resp.headers + + def test_csp_not_on_api_endpoints(self, security_client): + resp = security_client.get("/api/datasets/nonexistent") + assert "content-security-policy" not in resp.headers + + def test_headers_present_on_error_responses(self, security_client): + resp = security_client.get("/api/datasets/nonexistent") + assert resp.headers["x-content-type-options"] == "nosniff" + assert resp.headers["x-frame-options"] == "DENY" + + def test_no_hsts_header(self, security_client): + """HSTS is handled at the ingress layer, not by the app.""" + resp = security_client.get("/health") + assert "strict-transport-security" not in resp.headers + + +# ============================================================================ +# Content Size Limit Middleware +# ============================================================================ + + +class TestContentSizeLimit: + """Verify request body size enforcement.""" + + def test_small_body_accepted(self, security_client): + resp = security_client.post( + "/api/datasets/test/episodes/0/annotations/auto", + json={"data": "small"}, + ) + # Not 413; the request passes the size check (may fail on auth/routing) + assert resp.status_code != 413 + + def test_large_content_length_rejected(self, security_client): + resp = security_client.post( + "/api/datasets/test/episodes/0/detect", + content=b"x", + headers={"Content-Length": str(20 * 1024 * 1024), "Content-Type": "application/json"}, + ) + assert resp.status_code == 413 + assert resp.json()["detail"] == "Request body too large" + + +# ============================================================================ +# Exception Handlers +# ============================================================================ + + +class TestExceptionHandlers: + """Verify custom exception handlers prevent information leakage.""" + + def test_500_returns_generic_message(self, security_client): + """Internal errors should not leak stack traces.""" + resp = security_client.get("/health") + if resp.status_code == 500: + assert resp.json()["detail"] == "Internal server error" + + def test_404_does_not_leak_paths(self, security_client): + resp = security_client.get("/api/datasets/../../etc/passwd") + assert resp.status_code in (400, 404, 422) + + +# ============================================================================ +# CSRF Cookie Path +# ============================================================================ + + +class TestCsrfCookiePath: + """Verify CSRF cookie includes Path=/ attribute.""" + + def test_csrf_cookie_has_root_path(self, security_client): + resp = security_client.get("/api/csrf-token") + assert resp.status_code == 200 + cookie_header = resp.headers.get("set-cookie", "") + assert "Path=/" in cookie_header + + +# ============================================================================ +# CORS Hardening +# ============================================================================ + + +class TestCorsHardening: + """Verify CORS uses explicit method and header lists.""" + + def test_cors_preflight_allows_explicit_methods(self, security_client): + resp = security_client.options( + "/api/datasets", + headers={ + "Origin": "http://localhost:5173", + "Access-Control-Request-Method": "GET", + }, + ) + allowed = resp.headers.get("access-control-allow-methods", "") + assert "*" not in allowed + + def test_cors_preflight_allows_explicit_headers(self, security_client): + resp = security_client.options( + "/api/datasets", + headers={ + "Origin": "http://localhost:5173", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Content-Type", + }, + ) + allowed = resp.headers.get("access-control-allow-headers", "") + assert "*" not in allowed + + +# ============================================================================ +# Health Check Enhanced +# ============================================================================ + + +class TestEnhancedHealthCheck: + """Verify health check includes storage probe.""" + + def test_healthy_with_valid_storage(self, security_client): + resp = security_client.get("/health") + data = resp.json() + assert data["status"] in ("healthy", "degraded") + assert "api" in data["checks"] + assert "storage" in data["checks"] + + def test_degraded_returns_503(self, tmp_path): + """Health returns 503 when storage path does not exist.""" + import src.api.config as config_mod + import src.api.services.annotation_service as ann_mod + import src.api.services.dataset_service as ds_mod + + nonexistent = str(tmp_path / "does_not_exist") + os.environ["HMI_DATA_PATH"] = nonexistent + config_mod._app_config = None + ds_mod._dataset_service = None + ann_mod._annotation_service = None + + from src.api.main import app + + with TestClient(app) as c: + resp = c.get("/health") + data = resp.json() + assert data["checks"]["storage"] == "unhealthy" + assert data["status"] == "degraded" + assert resp.status_code == 503 + + config_mod._app_config = None + ds_mod._dataset_service = None + ann_mod._annotation_service = None + + +# ============================================================================ +# Detection Router Rate Limiting & CSRF +# ============================================================================ + + +class TestDetectionSecurity: + """Verify rate limiting decorators and CSRF on DELETE.""" + + def test_delete_detections_requires_csrf_when_auth_enabled(self, tmp_path): + """DELETE clear_detections requires CSRF token.""" + import src.api.config as config_mod + import src.api.services.annotation_service as ann_mod + import src.api.services.dataset_service as ds_mod + from src.api import auth as auth_mod + + os.environ["HMI_DATA_PATH"] = str(tmp_path) + os.environ.pop("DATAVIEWER_AUTH_DISABLED", None) + os.environ["DATAVIEWER_AUTH_PROVIDER"] = "apikey" + os.environ["DATAVIEWER_API_KEY"] = "test-key" + config_mod._app_config = None + ds_mod._dataset_service = None + ann_mod._annotation_service = None + auth_mod.reset_auth_provider() + + from src.api.main import app + + with TestClient(app) as c: + resp = c.delete( + "/api/datasets/test/episodes/0/detections", + headers={"X-API-Key": "test-key"}, + ) + assert resp.status_code == 403 + + os.environ["DATAVIEWER_AUTH_DISABLED"] = "true" + os.environ.pop("DATAVIEWER_AUTH_PROVIDER", None) + os.environ.pop("DATAVIEWER_API_KEY", None) + auth_mod.reset_auth_provider() + config_mod._app_config = None + ds_mod._dataset_service = None + ann_mod._annotation_service = None + + def test_detection_error_does_not_leak_details(self, security_client): + """POST detection failure should return generic message.""" + resp = security_client.post( + "/api/datasets/nonexistent/episodes/0/detect", + json={}, + ) + if resp.status_code == 500: + detail = resp.json().get("detail", "") + assert "Traceback" not in detail + assert "File " not in detail + + def test_detect_import_error_returns_503(self, security_client): + """ImportError during detection returns 503 with install hint.""" + from unittest.mock import AsyncMock, MagicMock + + import src.api.services.dataset_service as ds_mod + import src.api.services.detection_service as det_mod + from src.api.main import app + + mock_episode = MagicMock() + mock_episode.meta.length = 5 + + mock_ds = AsyncMock() + mock_ds.get_episode = AsyncMock(return_value=mock_episode) + mock_ds.get_frame_image = AsyncMock(return_value=None) + + mock_det = MagicMock() + mock_det.detect_episode = AsyncMock(side_effect=ImportError("No module named 'ultralytics'")) + + app.dependency_overrides[ds_mod.get_dataset_service] = lambda: mock_ds + app.dependency_overrides[det_mod.get_detection_service] = lambda: mock_det + try: + resp = security_client.post( + "/api/datasets/test-ds/episodes/0/detect", + json={"model": "yolo11n", "confidence": 0.25}, + ) + assert resp.status_code == 503 + assert "YOLO" in resp.json()["detail"] + finally: + app.dependency_overrides.pop(ds_mod.get_dataset_service, None) + app.dependency_overrides.pop(det_mod.get_detection_service, None) + + def test_detect_generic_exception_returns_500(self, security_client): + """Generic exception during detection returns 500.""" + from unittest.mock import AsyncMock, MagicMock + + import src.api.services.dataset_service as ds_mod + import src.api.services.detection_service as det_mod + from src.api.main import app + + mock_episode = MagicMock() + mock_episode.meta.length = 5 + + mock_ds = AsyncMock() + mock_ds.get_episode = AsyncMock(return_value=mock_episode) + mock_ds.get_frame_image = AsyncMock(return_value=None) + + mock_det = MagicMock() + mock_det.detect_episode = AsyncMock(side_effect=RuntimeError("GPU out of memory")) + + app.dependency_overrides[ds_mod.get_dataset_service] = lambda: mock_ds + app.dependency_overrides[det_mod.get_detection_service] = lambda: mock_det + try: + resp = security_client.post( + "/api/datasets/test-ds/episodes/0/detect", + json={"model": "yolo11n", "confidence": 0.25}, + ) + assert resp.status_code == 500 + assert resp.json()["detail"] == "Detection failed" + finally: + app.dependency_overrides.pop(ds_mod.get_dataset_service, None) + app.dependency_overrides.pop(det_mod.get_detection_service, None) + + +# ============================================================================ +# Middleware Unit Tests +# ============================================================================ + + +class TestSecurityHeadersMiddleware: + """Unit tests for SecurityHeadersMiddleware ASGI class.""" + + @pytest.mark.asyncio + async def test_adds_headers_to_http_response(self): + from src.api.middleware import SecurityHeadersMiddleware + + captured_headers = [] + + async def dummy_app(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + mw = SecurityHeadersMiddleware(dummy_app) + + async def mock_receive(): + return {"type": "http.request", "body": b""} + + async def mock_send(message): + if message["type"] == "http.response.start": + captured_headers.extend(message["headers"]) + + await mw({"type": "http", "path": "/other", "headers": []}, mock_receive, mock_send) + + header_names = [h[0] for h in captured_headers] + assert b"x-content-type-options" in header_names + assert b"x-frame-options" in header_names + assert b"content-security-policy" in header_names + + @pytest.mark.asyncio + async def test_no_csp_on_api_paths(self): + from src.api.middleware import SecurityHeadersMiddleware + + captured_headers = [] + + async def dummy_app(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + + mw = SecurityHeadersMiddleware(dummy_app) + + async def mock_receive(): + return {"type": "http.request", "body": b""} + + async def mock_send(message): + if message["type"] == "http.response.start": + captured_headers.extend(message["headers"]) + + await mw({"type": "http", "path": "/api/datasets", "headers": []}, mock_receive, mock_send) + + header_names = [h[0] for h in captured_headers] + assert b"x-content-type-options" in header_names + assert b"content-security-policy" not in header_names + + @pytest.mark.asyncio + async def test_skips_non_http_scopes(self): + from src.api.middleware import SecurityHeadersMiddleware + + calls = [] + + async def dummy_app(scope, receive, send): + calls.append(scope["type"]) + + mw = SecurityHeadersMiddleware(dummy_app) + await mw({"type": "websocket"}, None, None) + assert calls == ["websocket"] + + @pytest.mark.asyncio + async def test_skips_docs_paths(self): + from src.api.middleware import SecurityHeadersMiddleware + + captured_headers = [] + + async def dummy_app(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + + mw = SecurityHeadersMiddleware(dummy_app) + + async def mock_send(message): + if message["type"] == "http.response.start": + captured_headers.extend(message["headers"]) + + for path in ("/docs", "/redoc", "/openapi.json"): + captured_headers.clear() + await mw({"type": "http", "path": path, "headers": []}, None, mock_send) + header_names = [h[0] for h in captured_headers] + assert b"content-security-policy" not in header_names, f"CSP should not be on {path}" + + +class TestContentSizeLimitMiddleware: + """Unit tests for ContentSizeLimitMiddleware ASGI class.""" + + @pytest.mark.asyncio + async def test_rejects_large_content_length(self): + from src.api.middleware import ContentSizeLimitMiddleware + + captured = [] + + async def dummy_app(scope, receive, send): + pass + + mw = ContentSizeLimitMiddleware(dummy_app, max_content_length=100) + + async def mock_receive(): + return {"type": "http.request", "body": b""} + + async def mock_send(message): + captured.append(message) + + scope = {"type": "http", "headers": [(b"content-length", b"200")]} + await mw(scope, mock_receive, mock_send) + + status = next(m for m in captured if m["type"] == "http.response.start") + assert status["status"] == 413 + + @pytest.mark.asyncio + async def test_allows_small_body(self): + from src.api.middleware import ContentSizeLimitMiddleware + + app_called = [] + + async def dummy_app(scope, receive, send): + app_called.append(True) + + mw = ContentSizeLimitMiddleware(dummy_app, max_content_length=1000) + + async def mock_receive(): + return {"type": "http.request", "body": b"small"} + + scope = {"type": "http", "headers": [(b"content-length", b"5")]} + await mw(scope, mock_receive, lambda m: None) + assert app_called + + @pytest.mark.asyncio + async def test_rejects_streaming_body_exceeding_limit(self): + from src.api.middleware import ContentSizeLimitMiddleware + + captured = [] + chunk_count = 0 + + async def dummy_app(scope, receive, send): + nonlocal chunk_count + while True: + msg = await receive() + chunk_count += 1 + if not msg.get("more_body", False): + break + + mw = ContentSizeLimitMiddleware(dummy_app, max_content_length=10) + + chunks = [b"x" * 6, b"x" * 6] + chunk_iter = iter(chunks) + + async def mock_receive(): + try: + body = next(chunk_iter) + return {"type": "http.request", "body": body, "more_body": True} + except StopIteration: + return {"type": "http.request", "body": b"", "more_body": False} + + async def mock_send(message): + captured.append(message) + + scope = {"type": "http", "headers": []} + await mw(scope, mock_receive, mock_send) + + status = next(m for m in captured if m["type"] == "http.response.start") + assert status["status"] == 413 + + @pytest.mark.asyncio + async def test_skips_non_http_scopes(self): + from src.api.middleware import ContentSizeLimitMiddleware + + calls = [] + + async def dummy_app(scope, receive, send): + calls.append(scope["type"]) + + mw = ContentSizeLimitMiddleware(dummy_app) + await mw({"type": "websocket"}, None, None) + assert calls == ["websocket"] + + @pytest.mark.asyncio + async def test_invalid_content_length_passes_through(self): + """Non-numeric Content-Length is ignored and the request proceeds.""" + from src.api.middleware import ContentSizeLimitMiddleware + + app_called = [] + + async def dummy_app(scope, receive, send): + app_called.append(True) + + mw = ContentSizeLimitMiddleware(dummy_app, max_content_length=100) + + async def mock_receive(): + return {"type": "http.request", "body": b"ok"} + + scope = {"type": "http", "headers": [(b"content-length", b"not-a-number")]} + await mw(scope, mock_receive, lambda m: None) + assert app_called + + +# ============================================================================ +# Exception Handler Coverage +# ============================================================================ + + +class TestValidationExceptionHandler: + """Verify custom 422 handler strips internal paths.""" + + def test_invalid_path_param_returns_422(self, security_client): + """Sending invalid types triggers RequestValidationError handler.""" + resp = security_client.get("/api/datasets/valid/episodes/not-a-number/detections") + assert resp.status_code == 422 + data = resp.json() + assert "detail" in data + assert isinstance(data["detail"], list) + for error in data["detail"]: + assert "loc" in error + assert "msg" in error + assert "type" in error + + def test_unhandled_exception_returns_500(self, tmp_path): + """Force an unhandled exception to exercise the 500 handler.""" + from unittest.mock import MagicMock + + import src.api.config as config_mod + import src.api.services.annotation_service as ann_mod + import src.api.services.dataset_service as ds_mod + + os.environ["HMI_DATA_PATH"] = str(tmp_path) + config_mod._app_config = None + ds_mod._dataset_service = None + ann_mod._annotation_service = None + + import src.api.services.detection_service as det_mod + from src.api.main import app + + mock_det = MagicMock() + mock_det.get_cached = MagicMock(side_effect=RuntimeError("unexpected crash")) + + app.dependency_overrides[det_mod.get_detection_service] = lambda: mock_det + try: + with TestClient(app, raise_server_exceptions=False) as c: + resp = c.get("/api/datasets/test/episodes/0/detections") + assert resp.status_code == 500 + assert resp.json()["detail"] == "Internal server error" + finally: + app.dependency_overrides.pop(det_mod.get_detection_service, None) + config_mod._app_config = None + ds_mod._dataset_service = None + ann_mod._annotation_service = None + + +class TestHealthCheckBranches: + """Cover the remaining health check branches.""" + + def test_health_storage_no_base_path(self, tmp_path, monkeypatch): + """Service without base_path attribute returns healthy storage.""" + from unittest.mock import MagicMock + + import src.api.config as config_mod + import src.api.services.annotation_service as ann_mod + import src.api.services.dataset_service as ds_mod + + os.environ["HMI_DATA_PATH"] = str(tmp_path) + config_mod._app_config = None + ann_mod._annotation_service = None + + mock_service = MagicMock(spec=[]) + ds_mod._dataset_service = mock_service + + from src.api.main import app + + with TestClient(app) as c: + resp = c.get("/health") + data = resp.json() + assert data["checks"]["api"] == "healthy" + assert data["checks"]["storage"] == "healthy" + assert data["status"] == "healthy" + + config_mod._app_config = None + ds_mod._dataset_service = None + ann_mod._annotation_service = None + + def test_health_storage_exception(self, tmp_path, monkeypatch): + """Exercise the except branch in health check when get_dataset_service raises.""" + import src.api.config as config_mod + import src.api.services.annotation_service as ann_mod + import src.api.services.dataset_service as ds_mod + + os.environ["HMI_DATA_PATH"] = str(tmp_path) + config_mod._app_config = None + ds_mod._dataset_service = None + ann_mod._annotation_service = None + + from src.api.main import app + + def raise_error(): + raise RuntimeError("service init failed") + + monkeypatch.setattr(ds_mod, "get_dataset_service", raise_error) + + with TestClient(app) as c: + resp = c.get("/health") + data = resp.json() + assert data["checks"]["storage"] == "unhealthy" + assert data["status"] == "degraded" + assert resp.status_code == 503 + + config_mod._app_config = None + ds_mod._dataset_service = None + ann_mod._annotation_service = None diff --git a/data-management/viewer/backend/uv.lock b/data-management/viewer/backend/uv.lock index e0b0fb7c..6ed3ea05 100644 --- a/data-management/viewer/backend/uv.lock +++ b/data-management/viewer/backend/uv.lock @@ -681,6 +681,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "fastapi" version = "0.135.3" @@ -1326,6 +1338,7 @@ dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "python-multipart" }, + { name = "slowapi" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -1392,11 +1405,26 @@ requires-dist = [ { name = "schemathesis", marker = "extra == 'dev'", specifier = "==4.15.1" }, { name = "scikit-learn", marker = "extra == 'analysis'", specifier = "==1.8.0" }, { name = "scipy", marker = "extra == 'analysis'", specifier = "==1.17.1" }, + { name = "slowapi", specifier = "==0.1.9" }, { name = "ultralytics", marker = "extra == 'yolo'", specifier = "==8.4.37" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.44.0" }, ] provides-extras = ["dev", "azure", "analysis", "huggingface", "auth", "hdf5", "export", "yolo"] +[[package]] +name = "limits" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -2960,6 +2988,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slowapi" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0" @@ -3516,6 +3556,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, + { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + [[package]] name = "yarl" version = "1.23.0" diff --git a/data-management/viewer/frontend/tsconfig.json b/data-management/viewer/frontend/tsconfig.json index dd410c1e..bd48d231 100644 --- a/data-management/viewer/frontend/tsconfig.json +++ b/data-management/viewer/frontend/tsconfig.json @@ -16,7 +16,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, - "baseUrl": ".", "paths": { "@/*": ["./src/*"] } diff --git a/data-management/viewer/frontend/vite.config.ts b/data-management/viewer/frontend/vite.config.ts index 159e8927..b062f071 100644 --- a/data-management/viewer/frontend/vite.config.ts +++ b/data-management/viewer/frontend/vite.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ }, }, server: { + host: true, port: 5173, proxy: { '/api': {