diff --git a/.github/workflows/release-docker.yaml b/.github/workflows/release-docker.yaml index 5e275303..7e6c8577 100644 --- a/.github/workflows/release-docker.yaml +++ b/.github/workflows/release-docker.yaml @@ -64,8 +64,8 @@ jobs: provenance: mode=max sbom: true - deploy-to-dev: - name: Deploy to dev + wait-for-deploy: + name: Wait for dev deploy runs-on: ubuntu-latest needs: push-image if: github.ref == 'refs/heads/main' @@ -74,12 +74,25 @@ jobs: url: ${{ vars.APP_URL }} steps: - - name: Deploy via SSH - uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4 - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_PRIVATE_KEY }} - port: ${{ secrets.SSH_PORT || 22 }} - fingerprint: ${{ secrets.SSH_HOST_FINGERPRINT }} - script: deploy dev + - name: Poll /api/v1/version for new short SHA + env: + TARGET_SHA: ${{ github.sha }} + APP_URL: ${{ vars.APP_URL }} + CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID_DEPLOY }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET_DEPLOY }} + run: | + set -eu + SHORT="${TARGET_SHA:0:7}" + for i in $(seq 1 80); do + ver=$(curl -sS \ + -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \ + -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \ + "$APP_URL/api/v1/version/" | jq -r '.version // empty' 2>/dev/null || true) + if [[ "$ver" == *"g${SHORT}"* ]]; then + echo "Deployed: $ver" + exit 0 + fi + echo "want g${SHORT}, got ${ver:-}" + sleep 15 + done + exit 1 diff --git a/.gitignore b/.gitignore index 4291d297..ce6ebf09 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ todo.md # Data /data/ +# Demo seeding +/demo/ + # setuptools-scm version src/pypsa_app/backend/_version_info.py @@ -36,6 +39,11 @@ sdist/ var/ wheels/ *.egg-info/ +# Wails desktop build — source assets are tracked; compiled outputs and auto-generated files are not. +# The `build/` and `wheels/` patterns above would catch these, so negate them explicitly. +!desktop/build/ +desktop/build/windows/installer/wails_tools.nsh +!desktop/build/windows/wheels/ .installed.cfg *.egg @@ -80,8 +88,10 @@ htmlcov/ # Docker compose/compose.prod.yaml +compose/compose.demo.yaml compose/.env compose/.env.dev +compose/.env.demo # Alembic alembic/versions/*.pyc diff --git a/Dockerfile b/Dockerfile index dcca76b8..5bc41ff4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,8 +23,11 @@ COPY pyproject.toml uv.lock MANIFEST.in ./ COPY src/ src/ COPY .git/ .git/ -# Sync dependencies with uv -RUN uv sync --frozen --extra full --no-dev +# Skip setuptools_scm git lookup when .git is unavailable +ARG SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYPSA_APP= +RUN if [ -n "${SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYPSA_APP}" ]; then \ + export SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYPSA_APP="${SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYPSA_APP}"; \ + fi && uv sync --frozen --extra full --no-dev # Stage 2: Runtime stage (pypsa-app backend) FROM python:3.13-slim@sha256:d49c1ff87eb98eac346fc250f52925f726eb913c43a92854246dd03c9692ad67 AS backend diff --git a/README.md b/README.md index 21c0c3c9..7e697362 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ and custom integrations. > [!TIP] > There is a prototype deployed under [`https://app-dev.pypsa.org/`](https://app-dev.pypsa.org/). If you would like to have access, reach out to [@lkstrp](https://github.com/lkstrp/). +> [!TIP] +> A public read-only demo runs at [`https://demo.pypsa.org/`](https://demo.pypsa.org/), where uploads and runs are disabled and data is synthetic. + ### Roadmap - [ ] Upload Networks via interface diff --git a/architecture.excalidraw b/architecture.excalidraw new file mode 100644 index 00000000..7818c1a6 --- /dev/null +++ b/architecture.excalidraw @@ -0,0 +1,701 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "main-title", "type": "text", "x": 400, "y": 8, "width": 800, "height": 36, + "angle": 0, "strokeColor": "#1e293b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1000, "version": 1, "versionNonce": 1000, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "PyPSA App — Production Architecture", + "fontSize": 24, "fontFamily": 2, "textAlign": "center", "verticalAlign": "top", + "containerId": null, "originalText": "PyPSA App — Production Architecture", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "main-subtitle", "type": "text", "x": 400, "y": 46, "width": 800, "height": 20, + "angle": 0, "strokeColor": "#64748b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1001, "version": 1, "versionNonce": 1001, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "Full-stack PyPSA network analysis · FastAPI + SvelteKit · PostgreSQL · Redis · Celery", + "fontSize": 12, "fontFamily": 2, "textAlign": "center", "verticalAlign": "top", + "containerId": null, "originalText": "Full-stack PyPSA network analysis · FastAPI + SvelteKit · PostgreSQL · Redis · Celery", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "z-client-bg", "type": "rectangle", "x": 550, "y": 72, "width": 500, "height": 100, + "angle": 0, "strokeColor": "#93c5fd", "backgroundColor": "#eff6ff", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 0, + "opacity": 60, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1002, "version": 1, "versionNonce": 1002, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "z-client-lbl", "type": "text", "x": 558, "y": 76, "width": 60, "height": 16, + "angle": 0, "strokeColor": "#3b82f6", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1003, "version": 1, "versionNonce": 1003, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "CLIENT", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "CLIENT", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "client-box", "type": "rectangle", "x": 610, "y": 88, "width": 380, "height": 70, + "angle": 0, "strokeColor": "#3b82f6", "backgroundColor": "#bfdbfe", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1004, "version": 1, "versionNonce": 1004, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "client-text", "type": "text", "x": 630, "y": 96, "width": 340, "height": 54, + "angle": 0, "strokeColor": "#1e40af", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1005, "version": 1, "versionNonce": 1005, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "Browser / CLI Client\nHTTPS · REST API · SvelteKit SPA", + "fontSize": 13, "fontFamily": 2, "textAlign": "center", "verticalAlign": "middle", + "containerId": null, "originalText": "Browser / CLI Client\nHTTPS · REST API · SvelteKit SPA", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "z-edge-bg", "type": "rectangle", "x": 350, "y": 210, "width": 900, "height": 100, + "angle": 0, "strokeColor": "#c4b5fd", "backgroundColor": "#f5f3ff", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 0, + "opacity": 60, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1006, "version": 1, "versionNonce": 1006, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "z-edge-lbl", "type": "text", "x": 358, "y": 214, "width": 60, "height": 16, + "angle": 0, "strokeColor": "#7c3aed", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1007, "version": 1, "versionNonce": 1007, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "EDGE", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "EDGE", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "proxy-box", "type": "rectangle", "x": 400, "y": 226, "width": 800, "height": 74, + "angle": 0, "strokeColor": "#7c3aed", "backgroundColor": "#ede9fe", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1008, "version": 1, "versionNonce": 1008, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "proxy-text", "type": "text", "x": 420, "y": 234, "width": 760, "height": 58, + "angle": 0, "strokeColor": "#4c1d95", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1009, "version": 1, "versionNonce": 1009, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "Reverse Proxy (nginx / Traefik)\nTLS Termination · Rate Limiting · Static Assets · Load Balancing", + "fontSize": 13, "fontFamily": 2, "textAlign": "center", "verticalAlign": "middle", + "containerId": null, "originalText": "Reverse Proxy (nginx / Traefik)\nTLS Termination · Rate Limiting · Static Assets · Load Balancing", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "z-app-bg", "type": "rectangle", "x": 200, "y": 358, "width": 1200, "height": 210, + "angle": 0, "strokeColor": "#86efac", "backgroundColor": "#f0fdf4", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 0, + "opacity": 60, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1010, "version": 1, "versionNonce": 1010, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "z-app-lbl", "type": "text", "x": 208, "y": 362, "width": 140, "height": 16, + "angle": 0, "strokeColor": "#16a34a", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1011, "version": 1, "versionNonce": 1011, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "APPLICATION LAYER", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "APPLICATION LAYER", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "fastapi-box", "type": "rectangle", "x": 225, "y": 378, "width": 400, "height": 175, + "angle": 0, "strokeColor": "#16a34a", "backgroundColor": "#dcfce7", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1012, "version": 1, "versionNonce": 1012, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "fastapi-title", "type": "text", "x": 245, "y": 386, "width": 360, "height": 22, + "angle": 0, "strokeColor": "#14532d", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1013, "version": 1, "versionNonce": 1013, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "FastAPI Backend :8000", + "fontSize": 15, "fontFamily": 2, "textAlign": "center", "verticalAlign": "top", + "containerId": null, "originalText": "FastAPI Backend :8000", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "fastapi-detail", "type": "text", "x": 240, "y": 410, "width": 370, "height": 130, + "angle": 0, "strokeColor": "#166534", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1014, "version": 1, "versionNonce": 1014, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "• REST API — 10+ routers (networks,\n runs, auth, admin, plots, stats)\n• GitHub OAuth + session management\n• RBAC permission enforcement\n• File upload / streaming download\n• Task dispatch + Snakedispatch client\n• Serves frontend static files (prod)", + "fontSize": 11, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "• REST API — 10+ routers (networks,\n runs, auth, admin, plots, stats)\n• GitHub OAuth + session management\n• RBAC permission enforcement\n• File upload / streaming download\n• Task dispatch + Snakedispatch client\n• Serves frontend static files (prod)", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "celery-box", "type": "rectangle", "x": 975, "y": 378, "width": 400, "height": 175, + "angle": 0, "strokeColor": "#16a34a", "backgroundColor": "#dcfce7", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1015, "version": 1, "versionNonce": 1015, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "celery-title", "type": "text", "x": 995, "y": 386, "width": 360, "height": 22, + "angle": 0, "strokeColor": "#14532d", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1016, "version": 1, "versionNonce": 1016, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "Celery Workers (N replicas)", + "fontSize": 15, "fontFamily": 2, "textAlign": "center", "verticalAlign": "top", + "containerId": null, "originalText": "Celery Workers (N replicas)", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "celery-detail", "type": "text", "x": 990, "y": 410, "width": 370, "height": 130, + "angle": 0, "strokeColor": "#166534", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1017, "version": 1, "versionNonce": 1017, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "• Plot generation (Plotly / PyPSA)\n• Statistics computation\n• PyPSA network analysis\n• Run status background polling\n• Results stored in Redis cache\n• Fallback: InMemoryQueue (threads)\n (no Redis required in minimal mode)", + "fontSize": 11, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "• Plot generation (Plotly / PyPSA)\n• Statistics computation\n• PyPSA network analysis\n• Run status background polling\n• Results stored in Redis cache\n• Fallback: InMemoryQueue (threads)\n (no Redis required in minimal mode)", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "z-data-bg", "type": "rectangle", "x": 60, "y": 615, "width": 1480, "height": 180, + "angle": 0, "strokeColor": "#fdba74", "backgroundColor": "#fff7ed", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 0, + "opacity": 60, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1018, "version": 1, "versionNonce": 1018, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "z-data-lbl", "type": "text", "x": 68, "y": 619, "width": 100, "height": 16, + "angle": 0, "strokeColor": "#ea580c", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1019, "version": 1, "versionNonce": 1019, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "DATA LAYER", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "DATA LAYER", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "pg-box", "type": "rectangle", "x": 80, "y": 633, "width": 330, "height": 148, + "angle": 0, "strokeColor": "#ea580c", "backgroundColor": "#fed7aa", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1020, "version": 1, "versionNonce": 1020, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "pg-title", "type": "text", "x": 100, "y": 641, "width": 290, "height": 22, + "angle": 0, "strokeColor": "#7c2d12", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1021, "version": 1, "versionNonce": 1021, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "PostgreSQL :5432", + "fontSize": 15, "fontFamily": 2, "textAlign": "center", "verticalAlign": "top", + "containerId": null, "originalText": "PostgreSQL :5432", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "pg-detail", "type": "text", "x": 96, "y": 665, "width": 300, "height": 110, + "angle": 0, "strokeColor": "#9a3412", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1022, "version": 1, "versionNonce": 1022, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "• Users & roles (RBAC)\n• Networks metadata + file hashes\n• Runs & job tracking\n• API keys (hashed, never plaintext)\n• Snakedispatch backend registry\n• Alembic migrations (advisory lock)", + "fontSize": 11, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "• Users & roles (RBAC)\n• Networks metadata + file hashes\n• Runs & job tracking\n• API keys (hashed, never plaintext)\n• Snakedispatch backend registry\n• Alembic migrations (advisory lock)", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "redis-box", "type": "rectangle", "x": 625, "y": 633, "width": 350, "height": 148, + "angle": 0, "strokeColor": "#dc2626", "backgroundColor": "#fecaca", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1023, "version": 1, "versionNonce": 1023, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "redis-title", "type": "text", "x": 645, "y": 641, "width": 310, "height": 22, + "angle": 0, "strokeColor": "#7f1d1d", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1024, "version": 1, "versionNonce": 1024, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "Redis :6379", + "fontSize": 15, "fontFamily": 2, "textAlign": "center", "verticalAlign": "top", + "containerId": null, "originalText": "Redis :6379", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "redis-detail", "type": "text", "x": 641, "y": 665, "width": 320, "height": 110, + "angle": 0, "strokeColor": "#991b1b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1025, "version": 1, "versionNonce": 1025, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "• Session store (HTTPOnly cookies)\n• Celery message broker + backend\n• Plot & network result cache\n• TTL eviction (LRU policy)\n• plot_cache_ttl / network_cache_ttl\n• Max 2 GB default memory limit", + "fontSize": 11, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "• Session store (HTTPOnly cookies)\n• Celery message broker + backend\n• Plot & network result cache\n• TTL eviction (LRU policy)\n• plot_cache_ttl / network_cache_ttl\n• Max 2 GB default memory limit", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "files-box", "type": "rectangle", "x": 1190, "y": 633, "width": 330, "height": 148, + "angle": 0, "strokeColor": "#0284c7", "backgroundColor": "#e0f2fe", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1026, "version": 1, "versionNonce": 1026, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "files-title", "type": "text", "x": 1210, "y": 641, "width": 290, "height": 22, + "angle": 0, "strokeColor": "#0c4a6e", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1027, "version": 1, "versionNonce": 1027, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "File Storage (local / S3-compat)", + "fontSize": 13, "fontFamily": 2, "textAlign": "center", "verticalAlign": "top", + "containerId": null, "originalText": "File Storage (local / S3-compat)", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "files-detail", "type": "text", "x": 1206, "y": 665, "width": 300, "height": 110, + "angle": 0, "strokeColor": "#075985", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1028, "version": 1, "versionNonce": 1028, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "• .nc network files (NetCDF)\n• Run output files\n• Per-user directory isolation\n• Path-validated access (no traversal)\n• Max upload: 2 GB (configurable)\n• DATA_DIR env var", + "fontSize": 11, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "• .nc network files (NetCDF)\n• Run output files\n• Per-user directory isolation\n• Path-validated access (no traversal)\n• Max upload: 2 GB (configurable)\n• DATA_DIR env var", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "z-ext-bg", "type": "rectangle", "x": 60, "y": 858, "width": 1480, "height": 160, + "angle": 0, "strokeColor": "#cbd5e1", "backgroundColor": "#f8fafc", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 0, + "opacity": 60, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1029, "version": 1, "versionNonce": 1029, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "z-ext-lbl", "type": "text", "x": 68, "y": 862, "width": 150, "height": 16, + "angle": 0, "strokeColor": "#64748b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1030, "version": 1, "versionNonce": 1030, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "EXTERNAL SERVICES", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "EXTERNAL SERVICES", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "github-box", "type": "rectangle", "x": 80, "y": 876, "width": 300, "height": 128, + "angle": 0, "strokeColor": "#475569", "backgroundColor": "#f1f5f9", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1031, "version": 1, "versionNonce": 1031, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "github-title", "type": "text", "x": 100, "y": 884, "width": 260, "height": 22, + "angle": 0, "strokeColor": "#1e293b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1032, "version": 1, "versionNonce": 1032, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "GitHub OAuth 2.0", + "fontSize": 15, "fontFamily": 2, "textAlign": "center", "verticalAlign": "top", + "containerId": null, "originalText": "GitHub OAuth 2.0", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "github-detail", "type": "text", "x": 96, "y": 908, "width": 270, "height": 90, + "angle": 0, "strokeColor": "#334155", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1033, "version": 1, "versionNonce": 1033, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "• User authentication\n• Profile sync\n• Role: PENDING → USER\n (admin approval required)\n• Optional — auth can be disabled", + "fontSize": 11, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "• User authentication\n• Profile sync\n• Role: PENDING → USER\n (admin approval required)\n• Optional — auth can be disabled", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "snake-box", "type": "rectangle", "x": 625, "y": 876, "width": 350, "height": 128, + "angle": 0, "strokeColor": "#475569", "backgroundColor": "#f1f5f9", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1034, "version": 1, "versionNonce": 1034, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "snake-title", "type": "text", "x": 645, "y": 884, "width": 310, "height": 22, + "angle": 0, "strokeColor": "#1e293b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1035, "version": 1, "versionNonce": 1035, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "Snakedispatch Backends", + "fontSize": 15, "fontFamily": 2, "textAlign": "center", "verticalAlign": "top", + "containerId": null, "originalText": "Snakedispatch Backends", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "snake-detail", "type": "text", "x": 641, "y": 908, "width": 320, "height": 90, + "angle": 0, "strokeColor": "#334155", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1036, "version": 1, "versionNonce": 1036, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "• Workflow / Snakemake job execution\n• HTTP job submission API\n• Run status callbacks → FastAPI\n• Multi-backend support\n• Polled every 10 s (sync interval)", + "fontSize": 11, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "• Workflow / Snakemake job execution\n• HTTP job submission API\n• Run status callbacks → FastAPI\n• Multi-backend support\n• Polled every 10 s (sync interval)", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "smtp-box", "type": "rectangle", "x": 1190, "y": 876, "width": 330, "height": 128, + "angle": 0, "strokeColor": "#475569", "backgroundColor": "#f1f5f9", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1037, "version": 1, "versionNonce": 1037, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "smtp-title", "type": "text", "x": 1210, "y": 884, "width": 290, "height": 22, + "angle": 0, "strokeColor": "#1e293b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1038, "version": 1, "versionNonce": 1038, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "SMTP Server", + "fontSize": 15, "fontFamily": 2, "textAlign": "center", "verticalAlign": "top", + "containerId": null, "originalText": "SMTP Server", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "smtp-detail", "type": "text", "x": 1206, "y": 908, "width": 300, "height": 90, + "angle": 0, "strokeColor": "#334155", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1039, "version": 1, "versionNonce": 1039, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "• Run completion alerts\n• User pending-approval emails\n• Configurable provider\n (SendGrid / SES / self-hosted)\n• SMTP_HOST / PORT / credentials", + "fontSize": 11, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "• Run completion alerts\n• User pending-approval emails\n• Configurable provider\n (SendGrid / SES / self-hosted)\n• SMTP_HOST / PORT / credentials", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "minimal-callout", "type": "rectangle", "x": 1540, "y": 378, "width": 235, "height": 110, + "angle": 0, "strokeColor": "#ca8a04", "backgroundColor": "#fef9c3", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "dashed", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 3}, + "seed": 1040, "version": 1, "versionNonce": 1040, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false + }, + { + "id": "minimal-callout-text", "type": "text", "x": 1555, "y": 386, "width": 205, "height": 96, + "angle": 0, "strokeColor": "#92400e", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 1041, "version": 1, "versionNonce": 1041, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "⚙ Minimal Mode\nSQLite + InMemoryQueue\nNo Redis / PG needed\nIdeal for single-user\nor local development", + "fontSize": 11, "fontFamily": 2, "textAlign": "center", "verticalAlign": "top", + "containerId": null, "originalText": "⚙ Minimal Mode\nSQLite + InMemoryQueue\nNo Redis / PG needed\nIdeal for single-user\nor local development", + "lineHeight": 1.25, "autoResize": true + }, + { + "id": "arr1", "type": "arrow", + "x": 800, "y": 158, "width": 1, "height": 68, + "angle": 0, "strokeColor": "#3b82f6", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 2}, + "seed": 2001, "version": 1, "versionNonce": 2001, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "points": [[0, 0], [0, 68]], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, + "startArrowhead": null, "endArrowhead": "arrow" + }, + { + "id": "lbl1", "type": "text", "x": 808, "y": 180, "width": 80, "height": 16, + "angle": 0, "strokeColor": "#3b82f6", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 2002, "version": 1, "versionNonce": 2002, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "HTTPS :443", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "HTTPS :443", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "arr2", "type": "arrow", + "x": 560, "y": 300, "width": 135, "height": 78, + "angle": 0, "strokeColor": "#7c3aed", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 2}, + "seed": 2003, "version": 1, "versionNonce": 2003, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "points": [[0, 0], [-135, 78]], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, + "startArrowhead": null, "endArrowhead": "arrow" + }, + { + "id": "lbl2", "type": "text", "x": 440, "y": 328, "width": 80, "height": 16, + "angle": 0, "strokeColor": "#7c3aed", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 2004, "version": 1, "versionNonce": 2004, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "HTTP/WS", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "HTTP/WS", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "arr3", "type": "arrow", + "x": 330, "y": 553, "width": 85, "height": 80, + "angle": 0, "strokeColor": "#ea580c", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 2}, + "seed": 2005, "version": 1, "versionNonce": 2005, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "points": [[0, 0], [-85, 80]], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, + "startArrowhead": null, "endArrowhead": "arrow" + }, + { + "id": "lbl3", "type": "text", "x": 228, "y": 580, "width": 50, "height": 16, + "angle": 0, "strokeColor": "#ea580c", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 2006, "version": 1, "versionNonce": 2006, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "SQL", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "SQL", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "arr4", "type": "arrow", + "x": 460, "y": 553, "width": 320, "height": 80, + "angle": 0, "strokeColor": "#dc2626", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 2}, + "seed": 2007, "version": 1, "versionNonce": 2007, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "points": [[0, 0], [320, 80]], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, + "startArrowhead": "arrow", "endArrowhead": "arrow" + }, + { + "id": "lbl4", "type": "text", "x": 510, "y": 560, "width": 130, "height": 30, + "angle": 0, "strokeColor": "#dc2626", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 2008, "version": 1, "versionNonce": 2008, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "Sessions / Cache\nTask Queue", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "Sessions / Cache\nTask Queue", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "arr5", "type": "arrow", + "x": 595, "y": 553, "width": 740, "height": 80, + "angle": 0, "strokeColor": "#0284c7", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "dashed", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 2}, + "seed": 2009, "version": 1, "versionNonce": 2009, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "points": [[0, 0], [740, 80]], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, + "startArrowhead": null, "endArrowhead": "arrow" + }, + { + "id": "lbl5", "type": "text", "x": 870, "y": 556, "width": 80, "height": 16, + "angle": 0, "strokeColor": "#0284c7", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 2010, "version": 1, "versionNonce": 2010, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "read/write", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "read/write", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "arr6", "type": "arrow", + "x": 280, "y": 553, "width": 50, "height": 323, + "angle": 0, "strokeColor": "#64748b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "dashed", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 2}, + "seed": 2011, "version": 1, "versionNonce": 2011, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "points": [[0, 0], [-50, 323]], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, + "startArrowhead": null, "endArrowhead": "arrow" + }, + { + "id": "lbl6", "type": "text", "x": 200, "y": 706, "width": 60, "height": 16, + "angle": 0, "strokeColor": "#64748b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 2012, "version": 1, "versionNonce": 2012, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "OAuth2", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "OAuth2", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "arr7", "type": "arrow", + "x": 430, "y": 553, "width": 370, "height": 323, + "angle": 0, "strokeColor": "#64748b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "dashed", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 2}, + "seed": 2013, "version": 1, "versionNonce": 2013, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "points": [[0, 0], [370, 323]], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, + "startArrowhead": null, "endArrowhead": "arrow" + }, + { + "id": "lbl7", "type": "text", "x": 555, "y": 706, "width": 80, "height": 30, + "angle": 0, "strokeColor": "#64748b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 2014, "version": 1, "versionNonce": 2014, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "HTTP\nJobs API", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "HTTP\nJobs API", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "arr8", "type": "arrow", + "x": 610, "y": 553, "width": 715, "height": 323, + "angle": 0, "strokeColor": "#64748b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "dashed", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 2}, + "seed": 2015, "version": 1, "versionNonce": 2015, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "points": [[0, 0], [715, 323]], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, + "startArrowhead": null, "endArrowhead": "arrow" + }, + { + "id": "lbl8", "type": "text", "x": 1030, "y": 700, "width": 70, "height": 16, + "angle": 0, "strokeColor": "#64748b", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 2016, "version": 1, "versionNonce": 2016, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "SMTP/TLS", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "SMTP/TLS", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "arr9", "type": "arrow", + "x": 1120, "y": 553, "width": 270, "height": 80, + "angle": 0, "strokeColor": "#dc2626", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 2}, + "seed": 2017, "version": 1, "versionNonce": 2017, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "points": [[0, 0], [-270, 80]], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, + "startArrowhead": "arrow", "endArrowhead": "arrow" + }, + { + "id": "lbl9", "type": "text", "x": 920, "y": 560, "width": 70, "height": 30, + "angle": 0, "strokeColor": "#dc2626", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 2018, "version": 1, "versionNonce": 2018, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "Celery\nBroker", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "Celery\nBroker", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "arr10", "type": "arrow", + "x": 980, "y": 553, "width": 735, "height": 80, + "angle": 0, "strokeColor": "#ea580c", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 0, + "opacity": 80, "groupIds": [], "frameId": null, "roundness": {"type": 2}, + "seed": 2019, "version": 1, "versionNonce": 2019, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "points": [[0, 0], [-735, 80]], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, + "startArrowhead": null, "endArrowhead": "arrow" + }, + { + "id": "lbl10", "type": "text", "x": 570, "y": 558, "width": 90, "height": 30, + "angle": 0, "strokeColor": "#ea580c", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 2020, "version": 1, "versionNonce": 2020, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "Task SQL\nwrite-back", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "Task SQL\nwrite-back", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "arr11", "type": "arrow", + "x": 1320, "y": 553, "width": 35, "height": 80, + "angle": 0, "strokeColor": "#0284c7", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "dashed", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 2}, + "seed": 2021, "version": 1, "versionNonce": 2021, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "points": [[0, 0], [35, 80]], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, + "startArrowhead": null, "endArrowhead": "arrow" + }, + { + "id": "lbl11", "type": "text", "x": 1360, "y": 572, "width": 70, "height": 16, + "angle": 0, "strokeColor": "#0284c7", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 2022, "version": 1, "versionNonce": 2022, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "outputs", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "outputs", "lineHeight": 1.25, "autoResize": true + }, + { + "id": "arr12", "type": "arrow", + "x": 975, "y": 876, "width": 350, "height": 283, + "angle": 0, "strokeColor": "#475569", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "dashed", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": {"type": 2}, + "seed": 2023, "version": 1, "versionNonce": 2023, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "points": [[0, 0], [-350, -283]], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, + "startArrowhead": null, "endArrowhead": "arrow" + }, + { + "id": "lbl12", "type": "text", "x": 656, "y": 740, "width": 100, "height": 30, + "angle": 0, "strokeColor": "#475569", "backgroundColor": "transparent", + "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, + "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, + "seed": 2024, "version": 1, "versionNonce": 2024, "isDeleted": false, + "boundElements": [], "updated": 1746391200000, "link": null, "locked": false, + "text": "Callback\n(job done)", "fontSize": 10, "fontFamily": 2, "textAlign": "left", "verticalAlign": "top", + "containerId": null, "originalText": "Callback\n(job done)", "lineHeight": 1.25, "autoResize": true + } + ], + "appState": { + "viewBackgroundColor": "#fafafa", + "gridSize": null, + "scrollX": 0, + "scrollY": 0, + "zoom": {"value": 0.75} + }, + "files": {} +} diff --git a/compose/.env.example b/compose/.env.example index e99f8972..40cc3790 100644 --- a/compose/.env.example +++ b/compose/.env.example @@ -6,35 +6,56 @@ # Publicly accessible URL of the application BASE_URL=http://localhost:5173 +# Single-user local-dashboard deployment (the bare `pypsa-app` CLI). Enables zero-copy in-place network registration. Incompatible with any authentication. +LOCAL_MODE=false + +# Public read-only demo deployment. Disables all write endpoints, uses a shared 'demo' user. +DEMO_MODE=false + # File storage directory to store application data and network files -DATA_DIR=./data +DATA_DIR=PydanticUndefined # Database # -------- -# Database URL (SQLite and PostgreSQL is supported) -DATABASE_URL=sqlite:///./data/pypsa-app.db +# Database URL (SQLite and PostgreSQL are supported). Defaults to a SQLite file inside data_dir. +DATABASE_URL=__derive_from_data_dir__ # Authentication # -------------- -# Enable GitHub OAuth authentication -ENABLE_AUTH=false - # GitHub OAuth app client ID (create at https://github.com/settings/developers) -# GITHUB_CLIENT_ID= +# AUTH_GITHUB_CLIENT_ID= # GitHub OAuth app client secret -# GITHUB_CLIENT_SECRET= +# AUTH_GITHUB_CLIENT_SECRET= + +# Enable password based login +AUTH_PASSWORD_ENABLED=false # Secret key for session cookies (generate with: openssl rand -base64 32) -# SESSION_SECRET_KEY=dev-secret-key-change-in-production +SESSION_SECRET_KEY=dev-secret-key-change-in-production # Session time-to-live in seconds (default: 7 days) -# SESSION_TTL=604800 +SESSION_TTL=604800 + +# Networks +# -------- + +# Maximum network file upload size in megabytes +MAX_UPLOAD_SIZE_MB=2000 -# GitHub username that becomes admin on first login -# ADMIN_GITHUB_USERNAME= +# Runs +# ---- + +# Interval in seconds between background Snakedispatch sync cycles +SNAKEDISPATCH_SYNC_INTERVAL=10.0 + +# Comma-separated list of allowed domains for run callback URLs (e.g. hooks.myorg.dev,example.com). Callbacks are rejected unless the host matches. Empty disables callbacks entirely. +CALLBACK_URL_ALLOWED_DOMAINS= + +# Comma-separated list of Snakedispatch backends in name=url format (e.g. cluster-a=http://sd-a:8000,cluster-b=http://sd-b:8000) +# SNAKEDISPATCH_BACKENDS= # Redis # ----- @@ -48,17 +69,50 @@ ENABLE_AUTH=false # Time-to-live in seconds for network cache entries # NETWORK_CACHE_TTL=7200 +# Time-to-live in seconds for run output file list cache entries +# RUN_OUTPUTS_CACHE_TTL=10800 + # Maximum cache size in megabytes # MAX_CACHE_SIZE_MB=50 -# Executions -# ---------- +# Rate limiting +# ------------- -# Comma-separated list of Snakedispatch backends in name=url format -# SNAKEDISPATCH_BACKENDS=cluster-a=http://snakedispatch-a:8000,cluster-b=http://snakedispatch-b:8000 +# Enable per route rate limiting. Auto on when LOCAL_MODE is off. +# RATELIMIT_ENABLED= -# Time-to-live in seconds for run output file list cache entries -# RUN_OUTPUTS_CACHE_TTL=10800 +# Default per key rate limit applied to all routes +RATELIMIT_DEFAULT=120/minute + +# Rate limit for POST /auth/login/password +RATELIMIT_LOGIN=5/minute;20/hour + +# Rate limit for task queueing routes (plots, statistics). +RATELIMIT_EXPENSIVE=60/minute;600/hour + +# Trust the CF-Connecting-IP header as the client IP for rate limiting. Only enable when the app sits behind a Cloudflare tunnel. +TRUST_CLOUDFLARE_IP=false + +# Email +# ----- + +# SMTP server hostname (enables email notifications when set) +# SMTP_HOST= + +# SMTP server port +# SMTP_PORT=587 + +# SMTP authentication username +# SMTP_USERNAME= + +# SMTP authentication password +# SMTP_PASSWORD= + +# Use TLS/STARTTLS for SMTP connection +# SMTP_USE_TLS=true + +# Sender email address for notifications +# SMTP_FROM_ADDRESS=noreply@pypsa-app.local # Development # ----------- diff --git a/compose/compose.yaml b/compose/compose.yaml new file mode 100644 index 00000000..679f934d --- /dev/null +++ b/compose/compose.yaml @@ -0,0 +1,96 @@ +# Development setup - backend only (run frontend with npm run dev) +# Copy .env.dev.example to .env.dev and configure your settings + +services: + postgres: + image: postgres:18-alpine + container_name: pypsa-postgres + + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + # Uncomment for access Postgres on host machine + # ports: + # - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:8-alpine + container_name: pypsa-redis + + # Uncomment to access Redis from host machine + # ports: + # - "6379:6379" + command: redis-server --maxmemory ${REDIS_MAXMEMORY:-2gb} --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + pypsa-app: + build: + context: .. + dockerfile: Dockerfile + target: backend + container_name: pypsa-app + ports: + - "${APP_PORT:-8000}:8000" + + environment: + # These use docker network hostnames, so we construct them here + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + REDIS_URL: redis://redis:6379/0 + DATA_DIR: /data + DEBUG: true + volumes: + - ../src:/app/src + - ../data:/data + - ../alembic.ini:/app/alembic.ini:ro + - ../frontend/app/package.json:/app/frontend/app/package.json:ro + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + command: ["pypsa-app", "serve", "--reload", "--dev"] + + celery-worker: + build: + context: .. + dockerfile: Dockerfile + target: backend + container_name: pypsa-celery-worker + + environment: + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + REDIS_URL: redis://redis:6379/0 + DATA_DIR: /data + command: + [ + "celery", + "-A", + "pypsa_app.backend.task_queue.task_app", + "worker", + "--loglevel=${CELERY_LOGLEVEL:-info}", + "--concurrency=${CELERY_CONCURRENCY:-2}", + ] + volumes: + - ../src:/app/src + - ../data:/data + - ../alembic.ini:/app/alembic.ini:ro + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + +volumes: + postgres_data: diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 00000000..1b04c2dc --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,6 @@ +build/bin +node_modules +frontend/dist +# local go build outputs +pypsa-desktop +pypsa-desktop.exe diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 00000000..eefcd5c4 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,16 @@ +# README + +## About + +This is the official Wails Svelte template. + +## Live Development + +To run in live development mode, run `wails dev` in the project directory. This will run a Vite development +server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser +and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect +to this in your browser, and you can call your Go code from devtools. + +## Building + +To build a redistributable, production mode package, use `wails build`. diff --git a/desktop/about.go b/desktop/about.go new file mode 100644 index 00000000..e933f82a --- /dev/null +++ b/desktop/about.go @@ -0,0 +1,67 @@ +package main + +import ( + _ "embed" + "fmt" + "os/exec" + "strings" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +//go:embed versions.yaml +var versionsYAML []byte + +type pinnedVersions struct { + PypsaApp string + Snakedispatch string +} + +// loadPinnedVersions parses the embedded versions.yaml. +// The file is intentionally simple (two "key: value" lines) so we avoid +// pulling in a YAML library just for this. +func loadPinnedVersions() pinnedVersions { + v := pinnedVersions{PypsaApp: "unknown", Snakedispatch: "unknown"} + for _, line := range strings.Split(string(versionsYAML), "\n") { + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.Trim(strings.TrimSpace(parts[1]), `"`) + switch key { + case "pypsa-app": + v.PypsaApp = val + case "snakedispatch": + v.Snakedispatch = val + } + } + return v +} + +// detectPythonVersion runs the Python binary in the pypsa-app venv and returns +// the version string (e.g. "Python 3.13.1"). Returns "unknown" on any error. +func detectPythonVersion(dataDir string) string { + py := venvScript(dataDir, "pypsa-app", "python") + out, err := exec.Command(py, "--version").Output() + if err != nil { + return "unknown" + } + return strings.TrimSpace(string(out)) +} + +// ShowAbout displays a native dialog with version information. +// Called from the system tray "About" menu item. +func (a *App) ShowAbout() { + v := loadPinnedVersions() + py := detectPythonVersion(a.dataDir) + msg := fmt.Sprintf( + "pypsa-app: %s\nsnakedispatch: %s\nPython: %s", + v.PypsaApp, v.Snakedispatch, py, + ) + _, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{ + Type: runtime.InfoDialog, + Title: "About pypsa-desktop", + Message: msg, + }) +} diff --git a/desktop/app.go b/desktop/app.go new file mode 100644 index 00000000..86be611e --- /dev/null +++ b/desktop/app.go @@ -0,0 +1,173 @@ +package main + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// StatusEvent is sent to the frontend on each startup step. +type StatusEvent struct { + Phase string `json:"phase"` + Message string `json:"message"` + Pct int `json:"pct"` + Err string `json:"err,omitempty"` +} + +// App is the Wails application struct (control plane). +type App struct { + ctx context.Context + dataDir string + runtimeDir string + manager *ProcessManager +} + +func NewApp() *App { + dataDir := appDataDir() + runtimeDir := dispatchRuntimeDir() + logDir := filepath.Join(dataDir, "logs") + return &App{ + dataDir: dataDir, + runtimeDir: runtimeDir, + manager: NewProcessManager(dataDir, runtimeDir, logDir), + } +} + +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + a.initSystray() +} + +// domReady fires after the WebView DOM is parsed but the JS onMount may not have run yet. +// We wait for a "frontend:ready" signal from Svelte (emitted after EventsOn is registered), +// with a 500 ms fallback so we never get stuck if the event is missed. +func (a *App) domReady(_ context.Context) { + ready := make(chan struct{}, 1) + runtime.EventsOnce(a.ctx, "frontend:ready", func(...interface{}) { + select { + case ready <- struct{}{}: + default: + } + }) + go func() { + select { + case <-ready: + case <-time.After(500 * time.Millisecond): + } + a.runStartupSequence() + }() +} + +func (a *App) shutdown(_ context.Context) { + a.manager.Stop() +} + +func (a *App) emit(phase, msg string, pct int) { + runtime.EventsEmit(a.ctx, "status", StatusEvent{Phase: phase, Message: msg, Pct: pct}) +} + +func (a *App) emitErr(msg string) { + runtime.EventsEmit(a.ctx, "status", StatusEvent{Phase: "error", Message: msg, Pct: 0, Err: msg}) +} + +func (a *App) runStartupSequence() { + // 1. Ensure all data/log/config directories exist + if err := ensureDirs(a.dataDir, a.runtimeDir); err != nil { + a.emitErr(fmt.Sprintf("Failed to create data directories: %v", err)) + return + } + + // 2. Port conflict detection + a.emit("checking", "Checking for port conflicts…", 5) + if conflicts := checkPortConflicts(); len(conflicts) > 0 { + a.emitErr(fmt.Sprintf("Ports %v already in use. Close other applications and restart.", conflicts)) + return + } + + // 3. Prerequisites — warn but don't fail (Git/Pixi needed only for workflows) + a.emit("checking", "Checking prerequisites…", 10) + prereqs := CheckPrereqs() + var missing []string + for _, p := range prereqs { + if !p.Found { + missing = append(missing, p.Name) + } + } + if len(missing) > 0 { + a.emit("checking", + fmt.Sprintf("Warning: %s not found in PATH — Snakemake workflows may fail.", + strings.Join(missing, ", ")), + 10) + time.Sleep(2 * time.Second) + } + + // 4. First-launch setup (create venvs + install packages) + setup := NewSetup(a.dataDir) + if !setup.IsComplete() { + if err := setup.Run(15, 48, func(pct int, msg string) { + a.emit("setup", msg, pct) + }); err != nil { + a.emitErr(fmt.Sprintf( + "Setup failed: %v\n\nSee %s for details.", + err, filepath.Join(a.dataDir, "logs", "setup.log"), + )) + return + } + } else { + a.emit("setup", "Installation verified.", 48) + } + + // 5. Write snakedispatch config (idempotent — refreshes pixi_path each launch) + a.emit("setup", "Writing snakedispatch config…", 50) + if err := writeDispatchConfig(a.dataDir, a.runtimeDir); err != nil { + a.emitErr(fmt.Sprintf("Failed to write snakedispatch config: %v", err)) + return + } + + // 6. Spawn services and health-poll each + if err := a.manager.Start(func(pct int, msg string) { + a.emit("starting", msg, pct) + }); err != nil { + a.emitErr(fmt.Sprintf("Failed to start services: %v", err)) + return + } + + // 7. Start nav proxy, then navigate the WebView through it. + // The proxy injects a Back button + keyboard shortcuts into every HTML page. + startProxy(appPort, proxyPort) + a.emit("ready", "Ready!", 100) + runtime.WindowSetSize(a.ctx, 1280, 800) + runtime.WindowCenter(a.ctx) + runtime.EventsEmit(a.ctx, "navigate", fmt.Sprintf("http://localhost:%d", proxyPort)) + + go a.watchCrashes() +} + +// watchCrashes surfaces fatal sidecar errors to the user after successful launch. +// On Windows a toast notification is sent so the user is alerted even if the +// window is minimised to the tray. +func (a *App) watchCrashes() { + select { + case err := <-a.manager.ErrCh(): + msg := fmt.Sprintf("A service crashed and could not be restarted: %v", err) + notifyCrash(msg) + runtime.WindowShow(a.ctx) + a.emitErr(msg) + case <-a.ctx.Done(): + } +} + +// Quit stops services and exits. Callable from system tray and frontend. +func (a *App) Quit() { + a.manager.Stop() + runtime.Quit(a.ctx) +} + +// ShowWindow brings the main window to the foreground. Callable from system tray. +func (a *App) ShowWindow() { + runtime.WindowShow(a.ctx) +} diff --git a/desktop/before_close_other.go b/desktop/before_close_other.go new file mode 100644 index 00000000..6c58493a --- /dev/null +++ b/desktop/before_close_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package main + +import "context" + +// beforeClose allows the window to close normally on non-Windows platforms. +// No systray exists on macOS/Linux, so hiding would strand the app with no UI. +func (a *App) beforeClose(_ context.Context) bool { + return false +} diff --git a/desktop/before_close_windows.go b/desktop/before_close_windows.go new file mode 100644 index 00000000..582c79c8 --- /dev/null +++ b/desktop/before_close_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package main + +import ( + "context" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// beforeClose hides the window to the system tray instead of quitting. +func (a *App) beforeClose(ctx context.Context) bool { + runtime.WindowHide(ctx) + return true +} diff --git a/desktop/build/README.md b/desktop/build/README.md new file mode 100644 index 00000000..1ae2f677 --- /dev/null +++ b/desktop/build/README.md @@ -0,0 +1,35 @@ +# Build Directory + +The build directory is used to house all the build files and assets for your application. + +The structure is: + +* bin - Output directory +* darwin - macOS specific files +* windows - Windows specific files + +## Mac + +The `darwin` directory holds files specific to Mac builds. +These may be customised and used as part of the build. To return these files to the default state, simply delete them +and +build with `wails build`. + +The directory contains the following files: + +- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`. +- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`. + +## Windows + +The `windows` directory contains the manifest and rc files used when building with `wails build`. +These may be customised for your application. To return these files to the default state, simply delete them and +build with `wails build`. + +- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to + use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file + will be created using the `appicon.png` file in the build directory. +- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`. +- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer, + as well as the application itself (right click the exe -> properties -> details) +- `wails.exe.manifest` - The main application manifest file. \ No newline at end of file diff --git a/desktop/build/appicon.png b/desktop/build/appicon.png new file mode 100644 index 00000000..63617fe4 Binary files /dev/null and b/desktop/build/appicon.png differ diff --git a/desktop/build/darwin-x86/.gitignore b/desktop/build/darwin-x86/.gitignore new file mode 100644 index 00000000..00d4141b --- /dev/null +++ b/desktop/build/darwin-x86/.gitignore @@ -0,0 +1,4 @@ +# Built on macOS dev machine — not committed. +# See docs/desktop-build-and-distribute.md — macOS section for build instructions. +uv +wheels/**/*.whl diff --git a/desktop/build/darwin/.gitignore b/desktop/build/darwin/.gitignore new file mode 100644 index 00000000..00d4141b --- /dev/null +++ b/desktop/build/darwin/.gitignore @@ -0,0 +1,4 @@ +# Built on macOS dev machine — not committed. +# See docs/desktop-build-and-distribute.md — macOS section for build instructions. +uv +wheels/**/*.whl diff --git a/desktop/build/darwin/Info.dev.plist b/desktop/build/darwin/Info.dev.plist new file mode 100644 index 00000000..14121ef7 --- /dev/null +++ b/desktop/build/darwin/Info.dev.plist @@ -0,0 +1,68 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + com.wails.{{.Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/desktop/build/darwin/Info.plist b/desktop/build/darwin/Info.plist new file mode 100644 index 00000000..d17a7475 --- /dev/null +++ b/desktop/build/darwin/Info.plist @@ -0,0 +1,63 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + com.wails.{{.Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + + diff --git a/desktop/build/windows/.gitignore b/desktop/build/windows/.gitignore new file mode 100644 index 00000000..0e24f019 --- /dev/null +++ b/desktop/build/windows/.gitignore @@ -0,0 +1,4 @@ +# Built on dev machine — not committed. +# See docs/desktop-distribution.md — Windows section for build instructions. +uv.exe +installer/tmp/ diff --git a/desktop/build/windows/icon.ico b/desktop/build/windows/icon.ico new file mode 100644 index 00000000..f3347984 Binary files /dev/null and b/desktop/build/windows/icon.ico differ diff --git a/desktop/build/windows/info.json b/desktop/build/windows/info.json new file mode 100644 index 00000000..9727946b --- /dev/null +++ b/desktop/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "{{.Info.ProductVersion}}" + }, + "info": { + "0000": { + "ProductVersion": "{{.Info.ProductVersion}}", + "CompanyName": "{{.Info.CompanyName}}", + "FileDescription": "{{.Info.ProductName}}", + "LegalCopyright": "{{.Info.Copyright}}", + "ProductName": "{{.Info.ProductName}}", + "Comments": "{{.Info.Comments}}" + } + } +} \ No newline at end of file diff --git a/desktop/build/windows/installer/project.nsi b/desktop/build/windows/installer/project.nsi new file mode 100644 index 00000000..4d965ce8 --- /dev/null +++ b/desktop/build/windows/installer/project.nsi @@ -0,0 +1,169 @@ +Unicode true + +#### +## pypsa-desktop NSIS installer +## +## Build workflow (on a Windows machine with NSIS and makensis in PATH): +## +## Step 1 — populate wails_tools.nsh and build the Wails binary: +## cd desktop +## wails build --target windows/amd64 --nsis +## +## Step 2 — place supporting files next to this script's parent directory: +## desktop\build\windows\uv.exe (download from https://github.com/astral-sh/uv/releases) +## desktop\build\windows\wheels\pypsa-app\ (*.whl files built with `uv build`) +## desktop\build\windows\wheels\snakedispatch\ (*.whl files built with `uv build`) +## +## Step 3 — run makensis from this directory: +## makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\pypsa-desktop.exe project.nsi +## +## Output: desktop\build\bin\pypsa-desktop-setup-v-amd64.exe +#### + +; Pin product metadata here so wails_tools.nsh !ifndef guards leave them alone. +; Update INFO_PRODUCTVERSION when cutting a new release. +!define INFO_PROJECTNAME "pypsa-desktop" +!define INFO_COMPANYNAME "mikoding" +!define INFO_PRODUCTNAME "pypsa-desktop" +!define INFO_PRODUCTVERSION "0.1.0" +!define INFO_COPYRIGHT "Copyright 2025 mikoding" + +!include "wails_tools.nsh" +!include "LogicLib.nsh" + +; Version resource (must be 4-part integer, e.g. 0.1.0.0) +VIProductVersion "${INFO_PRODUCTVERSION}.0" +VIFileVersion "${INFO_PRODUCTVERSION}.0" + +VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" +VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" +VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" +VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" + +ManifestDPIAware true + +!include "MUI.nsh" + +!define MUI_ICON "..\icon.ico" +!define MUI_UNICON "..\icon.ico" +!define MUI_FINISHPAGE_NOAUTOCLOSE +!define MUI_ABORTWARNING + +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_INSTFILES + +!insertmacro MUI_LANGUAGE "English" + +; Code-signing hooks — uncomment and fill in cert path/password for release builds. +; Requires signtool.exe (Windows SDK) in PATH. +;!finalize 'signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f cert.pfx /p "${CERT_PASSWORD}" "%1"' +;!uninstfinalize 'signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f cert.pfx /p "${CERT_PASSWORD}" "%1"' + +Name "${INFO_PRODUCTNAME}" +OutFile "..\..\bin\${INFO_PROJECTNAME}-setup-v${INFO_PRODUCTVERSION}-${ARCH}.exe" +InstallDir "$PROGRAMFILES64\${INFO_PROJECTNAME}" +ShowInstDetails show + +; Global scratch registers +Var MissingPrereqs +Var ScratchStr + +Function .onInit + !insertmacro wails.checkArchitecture +FunctionEnd + +; ── Main install section ───────────────────────────────────────────────────── + +Section "pypsa-desktop" SEC_MAIN + !insertmacro wails.setShellContext + + ; Install WebView2 runtime if not already present (online bootstrapper). + !insertmacro wails.webview2runtime + + SetOutPath $INSTDIR + + ; Install the Wails binary (arch-specific, selected by wails.files macro). + !insertmacro wails.files + + ; Bundle uv binary — used by the app for offline Python environment creation. + ; Source: ..\uv.exe (desktop\build\windows\uv.exe) + File "/oname=uv.exe" "..\uv.exe" + + ; Bundle pre-built Python wheels for offline first-launch setup. + ; Placed in subdirs so setup.go's --find-links can locate them by venv name. + SetOutPath "$INSTDIR\wheels\pypsa-app" + File /r "..\wheels\pypsa-app\*" + + SetOutPath "$INSTDIR\wheels\snakedispatch" + File /r "..\wheels\snakedispatch\*" + + SetOutPath $INSTDIR + + ; Clear the setup sentinel so the new wheels are installed into venvs on next launch. + ; This ensures a reinstall (or upgrade) always picks up the freshly bundled packages. + Delete "$APPDATA\pypsa-desktop\venvs\setup_complete" + + CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + + !insertmacro wails.associateFiles + !insertmacro wails.associateCustomProtocols + !insertmacro wails.writeUninstaller +SectionEnd + +; ── Prerequisite check ─────────────────────────────────────────────────────── +; Runs as a hidden section (name starts with "-") after SEC_MAIN. +; The app repeats this check at every startup, so this is a convenience warning. + +Section "-PrerequisiteCheck" SEC_PREREQS + StrCpy $MissingPrereqs "" + + nsExec::ExecToStack '"$WINDIR\System32\where.exe" git' + Pop $0 ; exit code (0 = found) + Pop $1 ; stdout (discard) + ${If} $0 != 0 + StrCpy $ScratchStr "Git for Windows (https://git-scm.com/download/win)$\n" + StrCpy $MissingPrereqs "$MissingPrereqs$ScratchStr" + ${EndIf} + + nsExec::ExecToStack '"$WINDIR\System32\where.exe" pixi' + Pop $0 + Pop $1 + ${If} $0 != 0 + StrCpy $ScratchStr "Pixi (winget install prefix-dev.pixi)$\n" + StrCpy $MissingPrereqs "$MissingPrereqs$ScratchStr" + ${EndIf} + + ${If} $MissingPrereqs != "" + StrCpy $ScratchStr "$MissingPrereqs" + MessageBox MB_OK|MB_ICONINFORMATION \ + "pypsa-desktop installed successfully.$\n$\n\ +The following prerequisites were not detected on this machine.$\n\ +Snakemake workflow execution requires them:$\n$\n\ +$ScratchStr$\nInstall them and restart pypsa-desktop." + ${EndIf} +SectionEnd + +; ── Uninstaller ────────────────────────────────────────────────────────────── + +Section "uninstall" + !insertmacro wails.setShellContext + + ; Remove program files: exe, uv.exe, bundled wheels, and the uninstaller. + ; User data at %APPDATA%\pypsa-desktop\ (SQLite DB, Python venvs, logs, workflow + ; results) is deliberately NOT removed so the user's work is preserved. + RMDir /r $INSTDIR + + Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" + Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" + + !insertmacro wails.unassociateFiles + !insertmacro wails.unassociateCustomProtocols + !insertmacro wails.deleteUninstaller +SectionEnd diff --git a/desktop/build/windows/wails.exe.manifest b/desktop/build/windows/wails.exe.manifest new file mode 100644 index 00000000..17e1a238 --- /dev/null +++ b/desktop/build/windows/wails.exe.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + \ No newline at end of file diff --git a/desktop/build/windows/wheels/.gitignore b/desktop/build/windows/wheels/.gitignore new file mode 100644 index 00000000..344d81e1 --- /dev/null +++ b/desktop/build/windows/wheels/.gitignore @@ -0,0 +1,4 @@ +# Wheels are built manually on a Windows machine and placed here before running makensis. +# They are not committed to git (300-500 MB total). +# See docs/desktop-distribution.md — "Building the installer" for build instructions. +*.whl diff --git a/desktop/build/windows/wheels/pypsa-app/.gitkeep b/desktop/build/windows/wheels/pypsa-app/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/desktop/build/windows/wheels/snakedispatch/.gitkeep b/desktop/build/windows/wheels/snakedispatch/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/desktop/config.go b/desktop/config.go new file mode 100644 index 00000000..1fd1e951 --- /dev/null +++ b/desktop/config.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "text/template" +) + +// dispatchConfigTmpl is the snakedispatch.yaml written on every launch (idempotent). +// Top-level key is the backend type ("local") — not wrapped under "backends:". +// DATA_DIR sets the persisted API/state directory; it must not be the same as +// scratch_dir because snakedispatch syncs queryable copies of per-job snkmt.db +// into DATA_DIR/jobs// while workflows write the live database in +// scratch_dir/jobs//. +const dispatchConfigTmpl = `# snakedispatch local backend — managed by pypsa-desktop, do not edit manually. +# Regenerated on each launch. +local: + scratch_dir: "{{ .ScratchDir }}" + pixi_path: "{{ .PixiPath }}" + poll_interval: 5 + default_snakemake_args: ["--cores", "1"] +DATA_DIR: "{{ .DataDir }}" +` + +// writeDispatchConfig writes snakedispatch.yaml to /config/. +// Safe to call on every launch. +func writeDispatchConfig(dataDir, runtimeDir string) error { + pixiPath, err := exec.LookPath("pixi") + if err != nil { + pixiPath = "pixi" // will fail loudly at runtime if truly absent + } + + data := struct { + ScratchDir string + PixiPath string + DataDir string + }{ + ScratchDir: filepath.ToSlash(filepath.Join(runtimeDir, "snakedispatch")), + PixiPath: filepath.ToSlash(pixiPath), + DataDir: filepath.ToSlash(filepath.Join(runtimeDir, "snakedispatch-state")), + } + + configPath := filepath.Join(dataDir, "config", "snakedispatch.yaml") + f, err := os.Create(configPath) + if err != nil { + return fmt.Errorf("create snakedispatch.yaml: %w", err) + } + defer f.Close() + + tmpl, err := template.New("cfg").Parse(dispatchConfigTmpl) + if err != nil { + return err + } + return tmpl.Execute(f, data) +} diff --git a/desktop/frontend/README.md b/desktop/frontend/README.md new file mode 100644 index 00000000..a346289c --- /dev/null +++ b/desktop/frontend/README.md @@ -0,0 +1,63 @@ +# Svelte + Vite + +This template should help get you started developing with Svelte in Vite. + +## Recommended IDE Setup + +[VS Code](https://code.visualstudio.com/) + ++ [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). + +## Need an official Svelte framework? + +Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its +serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, +and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. + +## Technical considerations + +**Why use this over SvelteKit?** + +- It brings its own routing solution which might not be preferable for some users. +- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. + `vite dev` and `vite build` wouldn't work in a SvelteKit environment, for example. + +This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer +experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` +templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. + +Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been +structured similarly to SvelteKit so that it is easy to migrate. + +**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?** + +Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash +references keeps the default TypeScript setting of accepting type information from the entire workspace, while also +adding `svelte` and `vite/client` type information. + +**Why include `.vscode/extensions.json`?** + +Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to +install the recommended extension upon opening the project. + +**Why enable `checkJs` in the JS template?** + +It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. +This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of +JavaScript, it is trivial to change the configuration. + +**Why is HMR not preserving my local component state?** + +HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` +and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the +details [here](https://github.com/rixo/svelte-hmr#svelte-hmr). + +If you have state that's important to retain within a component, consider creating an external store which would not be +replaced by HMR. + +```js +// store.js +// An extremely simple external store +import { writable } from 'svelte/store' +export default writable(0) +``` diff --git a/desktop/frontend/index.html b/desktop/frontend/index.html new file mode 100644 index 00000000..bc132f5b --- /dev/null +++ b/desktop/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + pypsa-desktop + + +
+ + + diff --git a/desktop/frontend/jsconfig.json b/desktop/frontend/jsconfig.json new file mode 100644 index 00000000..3918b4fd --- /dev/null +++ b/desktop/frontend/jsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + "moduleResolution": "Node", + "target": "ESNext", + "module": "ESNext", + /** + * svelte-preprocess cannot figure out whether you have + * a value or a type, so tell TypeScript to enforce using + * `import type` instead of `import` for Types. + */ + "importsNotUsedAsValues": "error", + "isolatedModules": true, + "resolveJsonModule": true, + /** + * To have warnings / errors of the Svelte compiler at the + * correct position, enable source maps by default. + */ + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable this if you'd like to use dynamic types. + */ + "checkJs": true + }, + /** + * Use global.d.ts instead of compilerOptions.types + * to avoid limiting type declarations. + */ + "include": [ + "src/**/*.d.ts", + "src/**/*.js", + "src/**/*.svelte" + ] +} diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json new file mode 100644 index 00000000..389cbd07 --- /dev/null +++ b/desktop/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild"] + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^1.0.1", + "svelte": "^3.49.0", + "vite": "^3.0.7" + } +} \ No newline at end of file diff --git a/desktop/frontend/package.json.md5 b/desktop/frontend/package.json.md5 new file mode 100755 index 00000000..01114684 --- /dev/null +++ b/desktop/frontend/package.json.md5 @@ -0,0 +1 @@ +b8213bc68b08cabeec42faa5f6604ac8 \ No newline at end of file diff --git a/desktop/frontend/pnpm-lock.yaml b/desktop/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..50daf602 --- /dev/null +++ b/desktop/frontend/pnpm-lock.yaml @@ -0,0 +1,472 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^1.0.1 + version: 1.4.0(svelte@3.59.2)(vite@3.2.11) + svelte: + specifier: ^3.49.0 + version: 3.59.2 + vite: + specifier: ^3.0.7 + version: 3.2.11 + +packages: + + '@esbuild/android-arm@0.15.18': + resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/linux-loong64@0.15.18': + resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@sveltejs/vite-plugin-svelte@1.4.0': + resolution: {integrity: sha512-6QupI/jemMfK+yI2pMtJcu5iO2gtgTfcBdGwMZZt+lgbFELhszbDl6Qjh000HgAV8+XUA+8EY8DusOFk8WhOIg==} + engines: {node: ^14.18.0 || >= 16} + peerDependencies: + svelte: ^3.44.0 + vite: ^3.0.0 + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + esbuild-android-64@0.15.18: + resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + esbuild-android-arm64@0.15.18: + resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + esbuild-darwin-64@0.15.18: + resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + esbuild-darwin-arm64@0.15.18: + resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + esbuild-freebsd-64@0.15.18: + resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + esbuild-freebsd-arm64@0.15.18: + resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + esbuild-linux-32@0.15.18: + resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + esbuild-linux-64@0.15.18: + resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + esbuild-linux-arm64@0.15.18: + resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + esbuild-linux-arm@0.15.18: + resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + esbuild-linux-mips64le@0.15.18: + resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + esbuild-linux-ppc64le@0.15.18: + resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + esbuild-linux-riscv64@0.15.18: + resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + esbuild-linux-s390x@0.15.18: + resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + esbuild-netbsd-64@0.15.18: + resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + esbuild-openbsd-64@0.15.18: + resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + esbuild-sunos-64@0.15.18: + resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + esbuild-windows-32@0.15.18: + resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + esbuild-windows-64@0.15.18: + resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + esbuild-windows-arm64@0.15.18: + resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + esbuild@0.15.18: + resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==} + engines: {node: '>=12'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + magic-string@0.26.7: + resolution: {integrity: sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow==} + engines: {node: '>=12'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + rollup@2.80.0: + resolution: {integrity: sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==} + engines: {node: '>=10.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svelte-hmr@0.15.3: + resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: ^3.19.0 || ^4.0.0 + + svelte@3.59.2: + resolution: {integrity: sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==} + engines: {node: '>= 8'} + + vite@3.2.11: + resolution: {integrity: sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitefu@0.2.5: + resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + vite: + optional: true + +snapshots: + + '@esbuild/android-arm@0.15.18': + optional: true + + '@esbuild/linux-loong64@0.15.18': + optional: true + + '@sveltejs/vite-plugin-svelte@1.4.0(svelte@3.59.2)(vite@3.2.11)': + dependencies: + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.26.7 + svelte: 3.59.2 + svelte-hmr: 0.15.3(svelte@3.59.2) + vite: 3.2.11 + vitefu: 0.2.5(vite@3.2.11) + transitivePeerDependencies: + - supports-color + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deepmerge@4.3.1: {} + + es-errors@1.3.0: {} + + esbuild-android-64@0.15.18: + optional: true + + esbuild-android-arm64@0.15.18: + optional: true + + esbuild-darwin-64@0.15.18: + optional: true + + esbuild-darwin-arm64@0.15.18: + optional: true + + esbuild-freebsd-64@0.15.18: + optional: true + + esbuild-freebsd-arm64@0.15.18: + optional: true + + esbuild-linux-32@0.15.18: + optional: true + + esbuild-linux-64@0.15.18: + optional: true + + esbuild-linux-arm64@0.15.18: + optional: true + + esbuild-linux-arm@0.15.18: + optional: true + + esbuild-linux-mips64le@0.15.18: + optional: true + + esbuild-linux-ppc64le@0.15.18: + optional: true + + esbuild-linux-riscv64@0.15.18: + optional: true + + esbuild-linux-s390x@0.15.18: + optional: true + + esbuild-netbsd-64@0.15.18: + optional: true + + esbuild-openbsd-64@0.15.18: + optional: true + + esbuild-sunos-64@0.15.18: + optional: true + + esbuild-windows-32@0.15.18: + optional: true + + esbuild-windows-64@0.15.18: + optional: true + + esbuild-windows-arm64@0.15.18: + optional: true + + esbuild@0.15.18: + optionalDependencies: + '@esbuild/android-arm': 0.15.18 + '@esbuild/linux-loong64': 0.15.18 + esbuild-android-64: 0.15.18 + esbuild-android-arm64: 0.15.18 + esbuild-darwin-64: 0.15.18 + esbuild-darwin-arm64: 0.15.18 + esbuild-freebsd-64: 0.15.18 + esbuild-freebsd-arm64: 0.15.18 + esbuild-linux-32: 0.15.18 + esbuild-linux-64: 0.15.18 + esbuild-linux-arm: 0.15.18 + esbuild-linux-arm64: 0.15.18 + esbuild-linux-mips64le: 0.15.18 + esbuild-linux-ppc64le: 0.15.18 + esbuild-linux-riscv64: 0.15.18 + esbuild-linux-s390x: 0.15.18 + esbuild-netbsd-64: 0.15.18 + esbuild-openbsd-64: 0.15.18 + esbuild-sunos-64: 0.15.18 + esbuild-windows-32: 0.15.18 + esbuild-windows-64: 0.15.18 + esbuild-windows-arm64: 0.15.18 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + kleur@4.1.5: {} + + magic-string@0.26.7: + dependencies: + sourcemap-codec: 1.4.8 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rollup@2.80.0: + optionalDependencies: + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + sourcemap-codec@1.4.8: {} + + supports-preserve-symlinks-flag@1.0.0: {} + + svelte-hmr@0.15.3(svelte@3.59.2): + dependencies: + svelte: 3.59.2 + + svelte@3.59.2: {} + + vite@3.2.11: + dependencies: + esbuild: 0.15.18 + postcss: 8.5.15 + resolve: 1.22.12 + rollup: 2.80.0 + optionalDependencies: + fsevents: 2.3.3 + + vitefu@0.2.5(vite@3.2.11): + optionalDependencies: + vite: 3.2.11 diff --git a/desktop/frontend/src/App.svelte b/desktop/frontend/src/App.svelte new file mode 100644 index 00000000..5f970866 --- /dev/null +++ b/desktop/frontend/src/App.svelte @@ -0,0 +1,128 @@ + + +
+
+ + + {#if error} +

{error}

+ + {:else} +

{message}

+
+
+
+ {pct}% + {/if} +
+
+ + diff --git a/desktop/frontend/src/assets/fonts/OFL.txt b/desktop/frontend/src/assets/fonts/OFL.txt new file mode 100644 index 00000000..9cac04ce --- /dev/null +++ b/desktop/frontend/src/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com), + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/desktop/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 b/desktop/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 new file mode 100644 index 00000000..2f9cc596 Binary files /dev/null and b/desktop/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 differ diff --git a/desktop/frontend/src/assets/images/logo-universal.png b/desktop/frontend/src/assets/images/logo-universal.png new file mode 100644 index 00000000..d63303bf Binary files /dev/null and b/desktop/frontend/src/assets/images/logo-universal.png differ diff --git a/desktop/frontend/src/main.js b/desktop/frontend/src/main.js new file mode 100644 index 00000000..95c41a51 --- /dev/null +++ b/desktop/frontend/src/main.js @@ -0,0 +1,8 @@ +import './style.css' +import App from './App.svelte' + +const app = new App({ + target: document.getElementById('app') +}) + +export default app diff --git a/desktop/frontend/src/style.css b/desktop/frontend/src/style.css new file mode 100644 index 00000000..a9f4949c --- /dev/null +++ b/desktop/frontend/src/style.css @@ -0,0 +1,16 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + background: #111827; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + -webkit-font-smoothing: antialiased; +} + +#app { + height: 100vh; +} diff --git a/desktop/frontend/src/vite-env.d.ts b/desktop/frontend/src/vite-env.d.ts new file mode 100644 index 00000000..4078e747 --- /dev/null +++ b/desktop/frontend/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/desktop/frontend/vite.config.js b/desktop/frontend/vite.config.js new file mode 100644 index 00000000..d37616f9 --- /dev/null +++ b/desktop/frontend/vite.config.js @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite' +import {svelte} from '@sveltejs/vite-plugin-svelte' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [svelte()] +}) diff --git a/desktop/frontend/wailsjs/go/main/App.d.ts b/desktop/frontend/wailsjs/go/main/App.d.ts new file mode 100755 index 00000000..efb7f123 --- /dev/null +++ b/desktop/frontend/wailsjs/go/main/App.d.ts @@ -0,0 +1,8 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function Quit():Promise; + +export function ShowAbout():Promise; + +export function ShowWindow():Promise; diff --git a/desktop/frontend/wailsjs/go/main/App.js b/desktop/frontend/wailsjs/go/main/App.js new file mode 100755 index 00000000..83e1b179 --- /dev/null +++ b/desktop/frontend/wailsjs/go/main/App.js @@ -0,0 +1,15 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function Quit() { + return window['go']['main']['App']['Quit'](); +} + +export function ShowAbout() { + return window['go']['main']['App']['ShowAbout'](); +} + +export function ShowWindow() { + return window['go']['main']['App']['ShowWindow'](); +} diff --git a/desktop/frontend/wailsjs/runtime/package.json b/desktop/frontend/wailsjs/runtime/package.json new file mode 100644 index 00000000..1e7c8a5d --- /dev/null +++ b/desktop/frontend/wailsjs/runtime/package.json @@ -0,0 +1,24 @@ +{ + "name": "@wailsapp/runtime", + "version": "2.0.0", + "description": "Wails Javascript runtime library", + "main": "runtime.js", + "types": "runtime.d.ts", + "scripts": { + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wailsapp/wails.git" + }, + "keywords": [ + "Wails", + "Javascript", + "Go" + ], + "author": "Lea Anthony ", + "license": "MIT", + "bugs": { + "url": "https://github.com/wailsapp/wails/issues" + }, + "homepage": "https://github.com/wailsapp/wails#readme" +} diff --git a/desktop/frontend/wailsjs/runtime/runtime.d.ts b/desktop/frontend/wailsjs/runtime/runtime.d.ts new file mode 100644 index 00000000..3bbea848 --- /dev/null +++ b/desktop/frontend/wailsjs/runtime/runtime.d.ts @@ -0,0 +1,330 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export interface Position { + x: number; + y: number; +} + +export interface Size { + w: number; + h: number; +} + +export interface Screen { + isCurrent: boolean; + isPrimary: boolean; + width : number + height : number +} + +// Environment information such as platform, buildtype, ... +export interface EnvironmentInfo { + buildType: string; + platform: string; + arch: string; +} + +// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit) +// emits the given event. Optional data may be passed with the event. +// This will trigger any event listeners. +export function EventsEmit(eventName: string, ...data: any): void; + +// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name. +export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple) +// sets up a listener for the given event name, but will only trigger a given number times. +export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; + +// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce) +// sets up a listener for the given event name, but will only trigger once. +export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff) +// unregisters the listener for the given event name. +export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; + +// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall) +// unregisters all listeners. +export function EventsOffAll(): void; + +// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint) +// logs the given message as a raw message +export function LogPrint(message: string): void; + +// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace) +// logs the given message at the `trace` log level. +export function LogTrace(message: string): void; + +// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug) +// logs the given message at the `debug` log level. +export function LogDebug(message: string): void; + +// [LogError](https://wails.io/docs/reference/runtime/log#logerror) +// logs the given message at the `error` log level. +export function LogError(message: string): void; + +// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal) +// logs the given message at the `fatal` log level. +// The application will quit after calling this method. +export function LogFatal(message: string): void; + +// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo) +// logs the given message at the `info` log level. +export function LogInfo(message: string): void; + +// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning) +// logs the given message at the `warning` log level. +export function LogWarning(message: string): void; + +// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload) +// Forces a reload by the main application as well as connected browsers. +export function WindowReload(): void; + +// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp) +// Reloads the application frontend. +export function WindowReloadApp(): void; + +// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop) +// Sets the window AlwaysOnTop or not on top. +export function WindowSetAlwaysOnTop(b: boolean): void; + +// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme) +// *Windows only* +// Sets window theme to system default (dark/light). +export function WindowSetSystemDefaultTheme(): void; + +// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme) +// *Windows only* +// Sets window to light theme. +export function WindowSetLightTheme(): void; + +// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme) +// *Windows only* +// Sets window to dark theme. +export function WindowSetDarkTheme(): void; + +// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter) +// Centers the window on the monitor the window is currently on. +export function WindowCenter(): void; + +// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle) +// Sets the text in the window title bar. +export function WindowSetTitle(title: string): void; + +// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen) +// Makes the window full screen. +export function WindowFullscreen(): void; + +// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen) +// Restores the previous window dimensions and position prior to full screen. +export function WindowUnfullscreen(): void; + +// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen) +// Returns the state of the window, i.e. whether the window is in full screen mode or not. +export function WindowIsFullscreen(): Promise; + +// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize) +// Sets the width and height of the window. +export function WindowSetSize(width: number, height: number): void; + +// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize) +// Gets the width and height of the window. +export function WindowGetSize(): Promise; + +// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize) +// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMaxSize(width: number, height: number): void; + +// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize) +// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMinSize(width: number, height: number): void; + +// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition) +// Sets the window position relative to the monitor the window is currently on. +export function WindowSetPosition(x: number, y: number): void; + +// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition) +// Gets the window position relative to the monitor the window is currently on. +export function WindowGetPosition(): Promise; + +// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide) +// Hides the window. +export function WindowHide(): void; + +// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow) +// Shows the window, if it is currently hidden. +export function WindowShow(): void; + +// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise) +// Maximises the window to fill the screen. +export function WindowMaximise(): void; + +// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise) +// Toggles between Maximised and UnMaximised. +export function WindowToggleMaximise(): void; + +// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise) +// Restores the window to the dimensions and position prior to maximising. +export function WindowUnmaximise(): void; + +// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised) +// Returns the state of the window, i.e. whether the window is maximised or not. +export function WindowIsMaximised(): Promise; + +// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise) +// Minimises the window. +export function WindowMinimise(): void; + +// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise) +// Restores the window to the dimensions and position prior to minimising. +export function WindowUnminimise(): void; + +// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised) +// Returns the state of the window, i.e. whether the window is minimised or not. +export function WindowIsMinimised(): Promise; + +// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal) +// Returns the state of the window, i.e. whether the window is normal or not. +export function WindowIsNormal(): Promise; + +// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour) +// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels. +export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; + +// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall) +// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system. +export function ScreenGetAll(): Promise; + +// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl) +// Opens the given URL in the system browser. +export function BrowserOpenURL(url: string): void; + +// [Environment](https://wails.io/docs/reference/runtime/intro#environment) +// Returns information about the environment +export function Environment(): Promise; + +// [Quit](https://wails.io/docs/reference/runtime/intro#quit) +// Quits the application. +export function Quit(): void; + +// [Hide](https://wails.io/docs/reference/runtime/intro#hide) +// Hides the application. +export function Hide(): void; + +// [Show](https://wails.io/docs/reference/runtime/intro#show) +// Shows the application. +export function Show(): void; + +// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext) +// Returns the current text stored on clipboard +export function ClipboardGetText(): Promise; + +// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext) +// Sets a text on the clipboard +export function ClipboardSetText(text: string): Promise; + +// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop) +// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. +export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void + +// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff) +// OnFileDropOff removes the drag and drop listeners and handlers. +export function OnFileDropOff() :void + +// Check if the file path resolver is available +export function CanResolveFilePaths(): boolean; + +// Resolves file paths for an array of files +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/desktop/frontend/wailsjs/runtime/runtime.js b/desktop/frontend/wailsjs/runtime/runtime.js new file mode 100644 index 00000000..556621ee --- /dev/null +++ b/desktop/frontend/wailsjs/runtime/runtime.js @@ -0,0 +1,298 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export function LogPrint(message) { + window.runtime.LogPrint(message); +} + +export function LogTrace(message) { + window.runtime.LogTrace(message); +} + +export function LogDebug(message) { + window.runtime.LogDebug(message); +} + +export function LogInfo(message) { + window.runtime.LogInfo(message); +} + +export function LogWarning(message) { + window.runtime.LogWarning(message); +} + +export function LogError(message) { + window.runtime.LogError(message); +} + +export function LogFatal(message) { + window.runtime.LogFatal(message); +} + +export function EventsOnMultiple(eventName, callback, maxCallbacks) { + return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks); +} + +export function EventsOn(eventName, callback) { + return EventsOnMultiple(eventName, callback, -1); +} + +export function EventsOff(eventName, ...additionalEventNames) { + return window.runtime.EventsOff(eventName, ...additionalEventNames); +} + +export function EventsOffAll() { + return window.runtime.EventsOffAll(); +} + +export function EventsOnce(eventName, callback) { + return EventsOnMultiple(eventName, callback, 1); +} + +export function EventsEmit(eventName) { + let args = [eventName].slice.call(arguments); + return window.runtime.EventsEmit.apply(null, args); +} + +export function WindowReload() { + window.runtime.WindowReload(); +} + +export function WindowReloadApp() { + window.runtime.WindowReloadApp(); +} + +export function WindowSetAlwaysOnTop(b) { + window.runtime.WindowSetAlwaysOnTop(b); +} + +export function WindowSetSystemDefaultTheme() { + window.runtime.WindowSetSystemDefaultTheme(); +} + +export function WindowSetLightTheme() { + window.runtime.WindowSetLightTheme(); +} + +export function WindowSetDarkTheme() { + window.runtime.WindowSetDarkTheme(); +} + +export function WindowCenter() { + window.runtime.WindowCenter(); +} + +export function WindowSetTitle(title) { + window.runtime.WindowSetTitle(title); +} + +export function WindowFullscreen() { + window.runtime.WindowFullscreen(); +} + +export function WindowUnfullscreen() { + window.runtime.WindowUnfullscreen(); +} + +export function WindowIsFullscreen() { + return window.runtime.WindowIsFullscreen(); +} + +export function WindowGetSize() { + return window.runtime.WindowGetSize(); +} + +export function WindowSetSize(width, height) { + window.runtime.WindowSetSize(width, height); +} + +export function WindowSetMaxSize(width, height) { + window.runtime.WindowSetMaxSize(width, height); +} + +export function WindowSetMinSize(width, height) { + window.runtime.WindowSetMinSize(width, height); +} + +export function WindowSetPosition(x, y) { + window.runtime.WindowSetPosition(x, y); +} + +export function WindowGetPosition() { + return window.runtime.WindowGetPosition(); +} + +export function WindowHide() { + window.runtime.WindowHide(); +} + +export function WindowShow() { + window.runtime.WindowShow(); +} + +export function WindowMaximise() { + window.runtime.WindowMaximise(); +} + +export function WindowToggleMaximise() { + window.runtime.WindowToggleMaximise(); +} + +export function WindowUnmaximise() { + window.runtime.WindowUnmaximise(); +} + +export function WindowIsMaximised() { + return window.runtime.WindowIsMaximised(); +} + +export function WindowMinimise() { + window.runtime.WindowMinimise(); +} + +export function WindowUnminimise() { + window.runtime.WindowUnminimise(); +} + +export function WindowSetBackgroundColour(R, G, B, A) { + window.runtime.WindowSetBackgroundColour(R, G, B, A); +} + +export function ScreenGetAll() { + return window.runtime.ScreenGetAll(); +} + +export function WindowIsMinimised() { + return window.runtime.WindowIsMinimised(); +} + +export function WindowIsNormal() { + return window.runtime.WindowIsNormal(); +} + +export function BrowserOpenURL(url) { + window.runtime.BrowserOpenURL(url); +} + +export function Environment() { + return window.runtime.Environment(); +} + +export function Quit() { + window.runtime.Quit(); +} + +export function Hide() { + window.runtime.Hide(); +} + +export function Show() { + window.runtime.Show(); +} + +export function ClipboardGetText() { + return window.runtime.ClipboardGetText(); +} + +export function ClipboardSetText(text) { + return window.runtime.ClipboardSetText(text); +} + +/** + * Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * + * @export + * @callback OnFileDropCallback + * @param {number} x - x coordinate of the drop + * @param {number} y - y coordinate of the drop + * @param {string[]} paths - A list of file paths. + */ + +/** + * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. + * + * @export + * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target) + */ +export function OnFileDrop(callback, useDropTarget) { + return window.runtime.OnFileDrop(callback, useDropTarget); +} + +/** + * OnFileDropOff removes the drag and drop listeners and handlers. + */ +export function OnFileDropOff() { + return window.runtime.OnFileDropOff(); +} + +export function CanResolveFilePaths() { + return window.runtime.CanResolveFilePaths(); +} + +export function ResolveFilePaths(files) { + return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); +} \ No newline at end of file diff --git a/desktop/go.mod b/desktop/go.mod new file mode 100644 index 00000000..9d543757 --- /dev/null +++ b/desktop/go.mod @@ -0,0 +1,42 @@ +module pypsa-desktop + +go 1.23.0 + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 + github.com/energye/systray v1.0.3 + github.com/wailsapp/wails/v2 v2.12.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 +) + +require ( + github.com/bep/debounce v1.2.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) + +// replace github.com/wailsapp/wails/v2 v2.12.0 => /Users/mikoding/go/pkg/mod diff --git a/desktop/go.sum b/desktop/go.sum new file mode 100644 index 00000000..ffbb2c74 --- /dev/null +++ b/desktop/go.sum @@ -0,0 +1,87 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/energye/systray v1.0.3 h1:XnyjJCeRU5z00bpNOic2fGTKz/7yHZMZjWiGIVXDS+4= +github.com/energye/systray v1.0.3/go.mod h1:HelKhC3PXwv3ryDxbuQqV+7kAxAYNzE5cfdrerGOZTc= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/desktop/main.go b/desktop/main.go new file mode 100644 index 00000000..17369f7a --- /dev/null +++ b/desktop/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/windows" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + app := NewApp() + + err := wails.Run(&options.App{ + Title: "pypsa-desktop", + Width: 640, + Height: 420, + MinWidth: 800, + MinHeight: 600, + AssetServer: &assetserver.Options{Assets: assets}, + BackgroundColour: &options.RGBA{R: 17, G: 24, B: 39, A: 1}, + OnStartup: app.startup, + OnDomReady: app.domReady, + OnShutdown: app.shutdown, + OnBeforeClose: app.beforeClose, + Bind: []interface{}{app}, + Windows: &windows.Options{ + WebviewIsTransparent: false, + WindowIsTranslucent: false, + }, + }) + + if err != nil { + println("Error:", err.Error()) + } +} diff --git a/desktop/notify_other.go b/desktop/notify_other.go new file mode 100644 index 00000000..39127d7f --- /dev/null +++ b/desktop/notify_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +// notifyCrash is a no-op on non-Windows platforms. Crash events are surfaced +// via the in-app error state and the WebView window being brought to the front. +func notifyCrash(_ string) {} diff --git a/desktop/notify_windows.go b/desktop/notify_windows.go new file mode 100644 index 00000000..25b9482e --- /dev/null +++ b/desktop/notify_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package main + +import toast "git.sr.ht/~jackmordaunt/go-toast/v2" + +// notifyCrash sends a Windows toast notification when a sidecar crashes +// permanently (after exhausting its restart budget). The notification appears +// in the system tray / Action Centre even if the app window is minimised. +// Errors are silently ignored — the error is also surfaced in-app via emitErr. +func notifyCrash(msg string) { + n := toast.Notification{ + AppID: "pypsa-desktop", + Title: "pypsa-desktop — service crashed", + Body: msg, + } + _ = n.Push() +} diff --git a/desktop/paths.go b/desktop/paths.go new file mode 100644 index 00000000..6ca39e5c --- /dev/null +++ b/desktop/paths.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" +) + +// appDataDir returns the root data directory for pypsa-desktop. +// - Windows: %APPDATA%\pypsa-desktop +// - macOS: ~/Library/Application Support/pypsa-desktop +func appDataDir() string { + base, err := os.UserConfigDir() + if err != nil { + base = os.TempDir() + } + return filepath.Join(base, "pypsa-desktop") +} + +// dispatchRuntimeDir returns a path for snakedispatch job scratch/state files. +// Keep this separate from appDataDir so workflow shell commands do not inherit +// macOS "Application Support" paths that contain spaces. +func dispatchRuntimeDir() string { + base, err := os.UserCacheDir() + if err != nil { + base = os.TempDir() + } + return filepath.Join(base, "pypsa-desktop-runtime") +} + +// venvScript returns the path to a console_scripts entry point inside a uv-managed venv. +func venvScript(dataDir, venvName, scriptName string) string { + if runtime.GOOS == "windows" { + return filepath.Join(dataDir, "venvs", venvName, "Scripts", scriptName+".exe") + } + return filepath.Join(dataDir, "venvs", venvName, "bin", scriptName) +} + +// ensureDirs creates all required subdirectories under app and runtime roots. +func ensureDirs(dataDir, runtimeDir string) error { + for _, sub := range []string{"data", "logs", "config", "venvs"} { + if err := os.MkdirAll(filepath.Join(dataDir, sub), 0o755); err != nil { + return err + } + } + for _, sub := range []string{"snakedispatch", "snakedispatch-state"} { + if err := os.MkdirAll(filepath.Join(runtimeDir, sub), 0o755); err != nil { + return err + } + } + return nil +} diff --git a/desktop/prereqs.go b/desktop/prereqs.go new file mode 100644 index 00000000..184cd7c9 --- /dev/null +++ b/desktop/prereqs.go @@ -0,0 +1,26 @@ +package main + +import "os/exec" + +// PrereqResult describes the detection result for a single prerequisite. +type PrereqResult struct { + Name string `json:"name"` + Found bool `json:"found"` + Message string `json:"message,omitempty"` +} + +// CheckPrereqs detects Git and Pixi on PATH. +// Called during startup; missing tools produce warnings in the splash screen. +func CheckPrereqs() []PrereqResult { + return []PrereqResult{ + checkTool("Git", "git"), + checkTool("Pixi", "pixi"), + } +} + +func checkTool(name, cmd string) PrereqResult { + if _, err := exec.LookPath(cmd); err != nil { + return PrereqResult{Name: name, Found: false, Message: cmd + " not found in PATH"} + } + return PrereqResult{Name: name, Found: true} +} diff --git a/desktop/process.go b/desktop/process.go new file mode 100644 index 00000000..0a2ba23f --- /dev/null +++ b/desktop/process.go @@ -0,0 +1,276 @@ +package main + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "gopkg.in/natefinch/lumberjack.v2" +) + +const ( + appPort = 8765 + dispatchPort = 8766 + healthTimeout = 60 * time.Second + pollInterval = 500 * time.Millisecond + maxRestarts = 3 + shutdownTimeout = 5 * time.Second +) + +// Sidecar is a managed subprocess with automatic crash-recovery. +type Sidecar struct { + Name string + mk func() *exec.Cmd // factory; rebuilt on each restart + log *lumberjack.Logger + mu sync.Mutex + cmd *exec.Cmd + restart int + stopped bool +} + +// ProcessManager owns both sidecars and their full lifecycle. +type ProcessManager struct { + dataDir string + runtimeDir string + logDir string + ctx context.Context + cancel context.CancelFunc + dispatch *Sidecar + app *Sidecar + errCh chan error +} + +func NewProcessManager(dataDir, runtimeDir, logDir string) *ProcessManager { + ctx, cancel := context.WithCancel(context.Background()) + return &ProcessManager{ + dataDir: dataDir, + runtimeDir: runtimeDir, + logDir: logDir, + ctx: ctx, + cancel: cancel, + errCh: make(chan error, 2), + } +} + +// Start launches snakedispatch then pypsa-app, health-polling each before proceeding. +// progress(pct, msg) is called at each step so the splash screen stays updated. +func (pm *ProcessManager) Start(progress func(pct int, msg string)) error { + progress(55, "Starting snakedispatch…") + sd, err := pm.spawnSidecar("snakedispatch", pm.dispatchCmd) + if err != nil { + return fmt.Errorf("launch snakedispatch: %w", err) + } + pm.dispatch = sd + + progress(65, "Waiting for snakedispatch…") + if err := pm.pollHealth(dispatchPort); err != nil { + return err + } + + progress(78, "Starting pypsa-app…") + ap, err := pm.spawnSidecar("pypsa-app", pm.pypsaCmd) + if err != nil { + return fmt.Errorf("launch pypsa-app: %w", err) + } + pm.app = ap + + progress(90, "Waiting for pypsa-app…") + if err := pm.pollHealth(appPort); err != nil { + return err + } + + return nil +} + +// Stop gracefully terminates both sidecars (pypsa-app first, then snakedispatch). +func (pm *ProcessManager) Stop() { + pm.cancel() + pm.stopSidecar(pm.app) + pm.stopSidecar(pm.dispatch) +} + +// ErrCh receives a fatal error when a sidecar exhausts its restart budget. +func (pm *ProcessManager) ErrCh() <-chan error { return pm.errCh } + +// -- internal -- + +func (pm *ProcessManager) spawnSidecar(name string, mk func() *exec.Cmd) (*Sidecar, error) { + lj := &lumberjack.Logger{ + Filename: filepath.Join(pm.logDir, name+".log"), + MaxSize: 10, + MaxBackups: 3, + } + cmd := mk() + cmd.Stdout = lj + cmd.Stderr = lj + cmd.SysProcAttr = newProcessGroup() + + if err := cmd.Start(); err != nil { + return nil, err + } + s := &Sidecar{Name: name, mk: mk, log: lj, cmd: cmd} + go pm.watch(s) + return s, nil +} + +// watch monitors s and restarts it on unexpected exit, up to maxRestarts times. +func (pm *ProcessManager) watch(s *Sidecar) { + for { + waitErr := s.cmd.Wait() + + s.mu.Lock() + if s.stopped { + s.mu.Unlock() + return + } + s.restart++ + n := s.restart + if n > maxRestarts { + s.mu.Unlock() + pm.errCh <- fmt.Errorf("%s crashed %d times; last: %v", s.Name, n, waitErr) + return + } + s.mu.Unlock() + + select { + case <-pm.ctx.Done(): + return + case <-time.After(time.Duration(n) * time.Second): + } + + cmd := s.mk() + cmd.Stdout = s.log + cmd.Stderr = s.log + cmd.SysProcAttr = newProcessGroup() + + s.mu.Lock() + if startErr := cmd.Start(); startErr != nil { + s.mu.Unlock() + pm.errCh <- fmt.Errorf("%s restart %d failed: %w", s.Name, n, startErr) + return + } + s.cmd = cmd + s.mu.Unlock() + } +} + +func (pm *ProcessManager) stopSidecar(s *Sidecar) { + if s == nil { + return + } + s.mu.Lock() + s.stopped = true + cmd := s.cmd + s.mu.Unlock() + + if cmd == nil || cmd.Process == nil { + return + } + terminateProcess(cmd.Process) +} + +func (pm *ProcessManager) pollHealth(port int) error { + client := &http.Client{Timeout: 2 * time.Second} + url := fmt.Sprintf("http://127.0.0.1:%d/health", port) + deadline := time.Now().Add(healthTimeout) + + for time.Now().Before(deadline) { + select { + case <-pm.ctx.Done(): + return pm.ctx.Err() + default: + } + resp, err := client.Get(url) + if err == nil { + resp.Body.Close() + if resp.StatusCode < 500 { + return nil + } + } + time.Sleep(pollInterval) + } + return fmt.Errorf("service on :%d not healthy within %s", port, healthTimeout) +} + +// pypsaCmd builds a fresh exec.Cmd for pypsa-app. +// We use "serve" (not "open") so we can set SNAKEDISPATCH_BACKENDS. +// "open" forces LOCAL_MODE=true which blocks that env var, hiding the Runs view. +func (pm *ProcessManager) pypsaCmd() *exec.Cmd { + cmd := exec.Command( + venvScript(pm.dataDir, "pypsa-app", "pypsa-app"), + "serve", + "--host", "127.0.0.1", + "--port", fmt.Sprintf("%d", appPort), + ) + cmd.Env = append(filterEnv("SNAKEDISPATCH_BACKENDS"), + fmt.Sprintf("BASE_URL=http://127.0.0.1:%d", appPort), + "SNAKEDISPATCH_BACKENDS=local=http://127.0.0.1:"+fmt.Sprintf("%d", dispatchPort), + "DATA_DIR="+filepath.Join(pm.dataDir, "data"), + "DATABASE_URL=sqlite:///"+filepath.Join(pm.dataDir, "data", "pypsa-app.db"), + ) + return cmd +} + +// filterEnv returns os.Environ() with the given keys removed. +func filterEnv(strip ...string) []string { + blocked := make(map[string]bool, len(strip)) + for _, k := range strip { + blocked[k] = true + } + env := os.Environ() + out := make([]string, 0, len(env)) + for _, e := range env { + key := e + if i := strings.IndexByte(e, '='); i >= 0 { + key = e[:i] + } + if !blocked[key] { + out = append(out, e) + } + } + return out +} + +// dispatchCmd builds a fresh exec.Cmd for snakedispatch. +// snakedispatch has no console script; it is started via uvicorn directly. +func (pm *ProcessManager) dispatchCmd() *exec.Cmd { + configPath := filepath.Join(pm.dataDir, "config", "snakedispatch.yaml") + cmd := exec.Command( + venvScript(pm.dataDir, "snakedispatch", "uvicorn"), + "app.main:app", + "--host", "127.0.0.1", + "--port", fmt.Sprintf("%d", dispatchPort), + ) + cmd.Env = append(os.Environ(), + "SNAKEDISPATCH_CONFIG="+configPath, + "DATA_DIR="+filepath.Join(pm.runtimeDir, "snakedispatch"), + ) + return cmd +} + +func isPortInUse(port int) bool { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond) + if err != nil { + return false + } + conn.Close() + return true +} + +func checkPortConflicts() []int { + ports := []int{appPort, dispatchPort, proxyPort} + var taken []int + for _, p := range ports { + if isPortInUse(p) { + taken = append(taken, p) + } + } + return taken +} diff --git a/desktop/proxy.go b/desktop/proxy.go new file mode 100644 index 00000000..bc5d168a --- /dev/null +++ b/desktop/proxy.go @@ -0,0 +1,105 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +const proxyPort = 8767 + +// navInject is injected into every HTML page the proxy serves. +// It adds a floating Back button (visible when history.length > 1 and not at root) +// and keyboard shortcuts (Alt+Left / Cmd+[ for back, Alt+Right / Cmd+] for forward). +const navInject = `` + +// startProxy launches a reverse proxy that forwards to pypsa-app and injects +// the navigation script into every HTML response. It runs for the lifetime of +// the app; errors are non-fatal (worst case the user points at the direct URL). +func startProxy(appPort, pxPort int) { + target, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", appPort)) + + proxy := httputil.NewSingleHostReverseProxy(target) + + // Disable compression so ModifyResponse can work on plain text. + origDirector := proxy.Director + proxy.Director = func(req *http.Request) { + origDirector(req) + req.Header.Set("Accept-Encoding", "identity") + } + + proxy.ModifyResponse = func(resp *http.Response) error { + if !strings.Contains(resp.Header.Get("Content-Type"), "text/html") { + return nil + } + // Prevent WebView from caching index.html across launches; JS/CSS files + // are content-hashed so they can be cached safely. + resp.Header.Set("Cache-Control", "no-store") + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return err + } + // Inject before so it runs early; fall back to appending. + out := bytes.Replace(body, []byte(""), []byte(navInject+""), 1) + if bytes.Equal(out, body) { + out = append(body, []byte(navInject)...) + } + resp.Body = io.NopCloser(bytes.NewReader(out)) + resp.ContentLength = int64(len(out)) + resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(out))) + resp.Header.Del("Content-Encoding") + return nil + } + + go http.ListenAndServe(fmt.Sprintf("127.0.0.1:%d", pxPort), proxy) //nolint:errcheck +} diff --git a/desktop/setup.go b/desktop/setup.go new file mode 100644 index 00000000..38ffe66e --- /dev/null +++ b/desktop/setup.go @@ -0,0 +1,302 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const setupSentinel = "setup_complete" + +// Setup handles first-launch uv venv creation and package installation. +type Setup struct { + dataDir string + wheelsDir string // empty → install from PyPI (dev / no installer) + uvBin string +} + +func NewSetup(dataDir string) *Setup { + return &Setup{ + dataDir: dataDir, + wheelsDir: bundledWheelsDir(), + uvBin: findUV(), + } +} + +// IsComplete returns true when the sentinel file written at the end of Run() exists. +func (s *Setup) IsComplete() bool { + _, err := os.Stat(filepath.Join(s.dataDir, "venvs", setupSentinel)) + return err == nil +} + +// venvReady returns true when uvicorn exists in the venv. +// Both pypsa-app and snakedispatch install uvicorn as a dependency, making it +// a reliable proxy for "the package install completed successfully". +func (s *Setup) venvReady(name string) bool { + _, err := os.Stat(venvScript(s.dataDir, name, "uvicorn")) + return err == nil +} + +// Run creates both venvs and installs packages. +// pct is reported in [lo, hi]; output is appended to /logs/setup.log. +// Already-installed venvs are skipped so retries don't redo completed work. +func (s *Setup) Run(lo, hi int, progress func(pct int, msg string)) error { + logPath := filepath.Join(s.dataDir, "logs", "setup.log") + span := hi - lo + + type venvTask struct { + name, pkg string + lo, hi int + } + tasks := []venvTask{ + {"pypsa-app", "pypsa-app", lo, lo + span/2}, + {"snakedispatch", "snakedispatch", lo + span/2, hi}, + } + + for _, t := range tasks { + isLocalDev := s.wheelsDir == "" && devInstallSpec(t.name) != "" + + if s.venvReady(t.name) { + if !isLocalDev { + progress(t.hi, fmt.Sprintf("%s already installed.", t.name)) + continue + } + // Local dev: venv exists but reinstall to pick up source changes. + } else { + progress(t.lo, fmt.Sprintf("Creating %s environment…", t.name)) + if err := s.createVenv(t.name, logPath); err != nil { + return fmt.Errorf("Creating %s environment: %w", t.name, err) + } + } + + progress(t.lo+span/4, fmt.Sprintf("Installing %s…", t.name)) + if err := s.installPkg(t.name, t.pkg, logPath); err != nil { + return fmt.Errorf("Installing %s: %w", t.name, err) + } + } + + progress(hi, "Setup complete.") + // In production (bundled wheels) write the sentinel so setup is skipped on next launch. + // In dev mode skip it so local source changes are always picked up on restart. + if s.wheelsDir == "" { + return nil + } + return os.WriteFile( + filepath.Join(s.dataDir, "venvs", setupSentinel), + []byte("ok"), 0o644, + ) +} + +func (s *Setup) createVenv(name, logPath string) error { + venvPath := filepath.Join(s.dataDir, "venvs", name) + return s.run(logPath, s.uvBin, "venv", "--python", s.pythonVersion(), venvPath) +} + +// pythonVersion returns the CPython version to use for venv creation. +// In bundled mode, the version is inferred from the wheel filenames so that +// arm64 bundles (Python 3.13 wheels) and x86_64 bundles (Python 3.12 wheels) +// each get the correct interpreter without a code change per build. +// Falls back to "3.13" for dev/online mode. +func (s *Setup) pythonVersion() string { + if s.wheelsDir != "" { + if v := inferPythonVersion(s.wheelsDir); v != "" { + return v + } + } + return "3.13" +} + +// inferPythonVersion scans wheel filenames for a cpXYZ-cpXYZ tag (not abi3, +// not py3-none) and returns the matching Python version string (e.g. "3.12"). +func inferPythonVersion(wheelsDir string) string { + for _, pkg := range []string{"pypsa-app", "snakedispatch"} { + entries, _ := os.ReadDir(filepath.Join(wheelsDir, pkg)) + for _, e := range entries { + parts := strings.Split(strings.TrimSuffix(e.Name(), ".whl"), "-") + if len(parts) < 5 { + continue + } + pyTag, abiTag := parts[2], parts[3] + if abiTag == "abi3" || abiTag == "none" { + continue // skip stable-ABI and pure-python wheels + } + if strings.HasPrefix(pyTag, "cp") && len(pyTag) == 5 { + return string(pyTag[2]) + "." + pyTag[3:] + } + } + } + return "" +} + +func (s *Setup) installPkg(venvName, pkg, logPath string) error { + venvPath := filepath.Join(s.dataDir, "venvs", venvName) + args := []string{"pip", "install", "--python", venvPath} + + switch { + case s.wheelsDir != "": + // Production: install from bundled wheels, no internet needed. + wheelDir := filepath.Join(s.wheelsDir, venvName) + _ = appendLogLine(logPath, "installing "+pkg+" from bundled wheels: "+wheelDir) + args = append(args, "--no-index", "--find-links", wheelDir, pkg) + case devInstallSpec(venvName) != "": + // Dev: reinstall only this package from local source; leave deps alone. + src := devInstallSpec(venvName) + _ = appendLogLine(logPath, "installing "+pkg+" from local source: "+src) + args = append(args, "--reinstall-package", pkg, src) + default: + _ = appendLogLine(logPath, "installing "+pkg+" from package index") + args = append(args, pkg) + } + + return s.run(logPath, s.uvBin, args...) +} + +// devInstallSpec returns the install spec for a package in dev mode (no bundled wheels). +// Priority: PYPSA_DESKTOP_SRC_ env var → auto-detect local source → empty (PyPI). +func devInstallSpec(venvName string) string { + key := "PYPSA_DESKTOP_SRC_" + strings.ToUpper(strings.ReplaceAll(venvName, "-", "_")) + if src := os.Getenv(key); src != "" { + return src + } + return findLocalSrc(venvName) +} + +// findLocalSrc walks up from cwd (and from the executable's directory as a +// fallback) looking for a pyproject.toml that names venvName. The exe-path +// fallback is needed when running a macOS app bundle, where os.Getwd() is +// $HOME rather than the project directory. +func findLocalSrc(venvName string) string { + altName := strings.ReplaceAll(venvName, "-", "_") + matches := func(data []byte) bool { + c := string(data) + return strings.Contains(c, `"`+venvName+`"`) || strings.Contains(c, `"`+altName+`"`) + } + + if cwd, err := os.Getwd(); err == nil { + if src := walkUpSrc(cwd, matches, 6); src != "" { + return src + } + } + // Fallback for app bundles: walk up from the executable. + // e.g. build/bin/pypsa-desktop.app/Contents/MacOS/pypsa-desktop → 7 levels up → project root. + if exe, err := os.Executable(); err == nil { + if src := walkUpSrc(filepath.Dir(exe), matches, 9); src != "" { + return src + } + } + return "" +} + +// walkUpSrc walks up at most maxLevels from startDir looking for a +// pyproject.toml that matches. Also scans sibling dirs of the first ancestor +// that has any pyproject.toml (handles packages in sibling repos). +func walkUpSrc(startDir string, matches func([]byte) bool, maxLevels int) string { + var siblingParent string + dir := startDir + for range maxLevels { + data, err := os.ReadFile(filepath.Join(dir, "pyproject.toml")) + if err == nil { + if matches(data) { + return dir + } + if siblingParent == "" { + siblingParent = filepath.Dir(dir) + } + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + if siblingParent == "" { + return "" + } + entries, _ := os.ReadDir(siblingParent) + for _, e := range entries { + if !e.IsDir() { + continue + } + candidate := filepath.Join(siblingParent, e.Name()) + data, err := os.ReadFile(filepath.Join(candidate, "pyproject.toml")) + if err == nil && matches(data) { + return candidate + } + } + return "" +} + +// run executes a command, appending its stdout+stderr to logPath. +func (s *Setup) run(logPath, name string, args ...string) error { + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + + cmd := exec.Command(name, args...) + cmd.Stdout = f + cmd.Stderr = f + return cmd.Run() +} + +func appendLogLine(logPath, line string) error { + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + + _, err = fmt.Fprintln(f, line) + return err +} + +func findUV() string { + if p := bundledUVPath(); p != "" { + if _, err := os.Stat(p); err == nil { + return p + } + } + if p, err := exec.LookPath("uv"); err == nil { + return p + } + return "uv" +} + +func bundledUVPath() string { + switch runtime.GOOS { + case "windows": + return `C:\Program Files\pypsa-desktop\uv.exe` + case "darwin": + exe, err := os.Executable() + if err != nil { + return "" + } + return filepath.Join(filepath.Dir(exe), "uv") + default: + return "" + } +} + +func bundledWheelsDir() string { + var dir string + switch runtime.GOOS { + case "windows": + dir = `C:\Program Files\pypsa-desktop\wheels` + case "darwin": + exe, err := os.Executable() + if err != nil { + return "" + } + dir = filepath.Join(filepath.Dir(exe), "wheels") + default: + return "" + } + if _, err := os.Stat(dir); err != nil { + return "" + } + return dir +} diff --git a/desktop/systray_other.go b/desktop/systray_other.go new file mode 100644 index 00000000..d674ffd5 --- /dev/null +++ b/desktop/systray_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package main + +func (a *App) initSystray() {} diff --git a/desktop/systray_windows.go b/desktop/systray_windows.go new file mode 100644 index 00000000..dfb8885d --- /dev/null +++ b/desktop/systray_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package main + +import ( + _ "embed" + + "github.com/energye/systray" +) + +//go:embed build/appicon.png +var trayIcon []byte + +func (a *App) initSystray() { + go systray.Run(func() { + systray.SetIcon(trayIcon) + systray.SetTooltip("pypsa-desktop") + + mShow := systray.AddMenuItem("Show Window", "Show the main window") + mShow.Click(func() { a.ShowWindow() }) + + systray.AddSeparator() + + mAbout := systray.AddMenuItem("About", "Show version information") + mAbout.Click(func() { a.ShowAbout() }) + + systray.AddSeparator() + + mQuit := systray.AddMenuItem("Quit", "Quit pypsa-desktop") + mQuit.Click(func() { + systray.Quit() + a.Quit() + }) + }, func() {}) +} diff --git a/desktop/terminate_other.go b/desktop/terminate_other.go new file mode 100644 index 00000000..52f37623 --- /dev/null +++ b/desktop/terminate_other.go @@ -0,0 +1,26 @@ +//go:build !windows + +package main + +import ( + "os" + "syscall" + "time" +) + +func terminateProcess(p *os.Process) { + ch := make(chan struct{}) + go func() { p.Wait(); close(ch) }() //nolint:errcheck + + p.Signal(syscall.SIGTERM) //nolint:errcheck + + select { + case <-ch: + case <-time.After(shutdownTimeout): + p.Kill() //nolint:errcheck + } +} + +func newProcessGroup() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setpgid: true} +} diff --git a/desktop/terminate_windows.go b/desktop/terminate_windows.go new file mode 100644 index 00000000..97947399 --- /dev/null +++ b/desktop/terminate_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +package main + +import ( + "os" + "syscall" + "time" +) + +func terminateProcess(p *os.Process) { + ch := make(chan struct{}) + go func() { p.Wait(); close(ch) }() //nolint:errcheck + + // CTRL_BREAK_EVENT reaches processes started in a new process group. + dll := syscall.MustLoadDLL("kernel32.dll") + ctrl := dll.MustFindProc("GenerateConsoleCtrlEvent") + ctrl.Call(uintptr(syscall.CTRL_BREAK_EVENT), uintptr(p.Pid)) //nolint:errcheck + + select { + case <-ch: + case <-time.After(shutdownTimeout): + p.Kill() //nolint:errcheck + } +} + +func newProcessGroup() *syscall.SysProcAttr { + return &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP} +} diff --git a/desktop/versions.yaml b/desktop/versions.yaml new file mode 100644 index 00000000..fa92fcd9 --- /dev/null +++ b/desktop/versions.yaml @@ -0,0 +1,2 @@ +pypsa-app: "0.1.0" +snakedispatch: "0.1.0" diff --git a/desktop/wails.json b/desktop/wails.json new file mode 100644 index 00000000..4e5d5c78 --- /dev/null +++ b/desktop/wails.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "pypsa-desktop", + "outputfilename": "pypsa-desktop", + "frontend:install": "pnpm install", + "frontend:build": "pnpm run build", + "frontend:dev:watcher": "pnpm run dev", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "mikoding", + "email": "mikoding9@gmail.com" + }, + "info": { + "companyName": "mikoding", + "productName": "pypsa-desktop", + "productVersion": "0.1.0", + "copyright": "Copyright 2025 mikoding", + "comments": "Network planning and dispatch optimisation desktop app" + } +} diff --git a/docs/configuration.md b/docs/configuration.md index bf05b166..729f1276 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,30 +7,39 @@ Environment variables for PyPSA App. | Variable | Description | Default | |----------|-------------|---------| | `BASE_URL` | Publicly accessible URL of the application | `http://localhost:5173` | -| `DATA_DIR` | File storage directory to store application data and network files | `./data` | +| `LOCAL_MODE` | Single-user local-dashboard deployment (the bare `pypsa-app` CLI). Enables zero-copy in-place network registration. Incompatible with any authentication. | `false` | +| `DEMO_MODE` | Public read-only demo deployment. Disables all write endpoints, uses a shared 'demo' user. | `false` | +| `DATA_DIR` | File storage directory to store application data and network files | `PydanticUndefined` | ## Database | Variable | Description | Default | |----------|-------------|---------| -| `DATABASE_URL` | Database URL (SQLite and PostgreSQL is supported) | `sqlite:///./data/pypsa-app.db` | +| `DATABASE_URL` | Database URL (SQLite and PostgreSQL are supported). Defaults to a SQLite file inside data_dir. | `__derive_from_data_dir__` | ## Authentication | Variable | Description | Default | |----------|-------------|---------| -| `ENABLE_AUTH` | Enable GitHub OAuth authentication | `false` | -| `GITHUB_CLIENT_ID` | GitHub OAuth app client ID (create at https://github.com/settings/developers) | - | -| `GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret | - | +| `AUTH_GITHUB_CLIENT_ID` | GitHub OAuth app client ID (create at https://github.com/settings/developers) | - | +| `AUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret | - | +| `AUTH_PASSWORD_ENABLED` | Enable password based login | `false` | | `SESSION_SECRET_KEY` | Secret key for session cookies (generate with: openssl rand -base64 32) | `dev-secret-key-change-in-production` | | `SESSION_TTL` | Session time-to-live in seconds (default: 7 days) | `604800` | -| `ADMIN_GITHUB_USERNAME` | GitHub username that becomes admin on first login | - | -## Map +## Networks | Variable | Description | Default | |----------|-------------|---------| -| `MAPBOX_TOKEN` | Mapbox access token for interactive network map via kepler.gl | - | +| `MAX_UPLOAD_SIZE_MB` | Maximum network file upload size in megabytes | `2000` | + +## Runs + +| Variable | Description | Default | +|----------|-------------|---------| +| `SNAKEDISPATCH_SYNC_INTERVAL` | Interval in seconds between background Snakedispatch sync cycles | `10.0` | +| `CALLBACK_URL_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for run callback URLs (e.g. hooks.myorg.dev,example.com). Callbacks are rejected unless the host matches. Empty disables callbacks entirely. | `` | +| `SNAKEDISPATCH_BACKENDS` | Comma-separated list of Snakedispatch backends in name=url format (e.g. cluster-a=http://sd-a:8000,cluster-b=http://sd-b:8000) | - | ## Redis @@ -38,10 +47,31 @@ Environment variables for PyPSA App. |----------|-------------|---------| | `REDIS_URL` | Redis connection URL for caching (optional) | - | | `PLOT_CACHE_TTL` | Time-to-live in seconds for plot cache entries | `86400` | -| `MAP_CACHE_TTL` | Time-to-live in seconds for map cache entries | `3600` | | `NETWORK_CACHE_TTL` | Time-to-live in seconds for network cache entries | `7200` | +| `RUN_OUTPUTS_CACHE_TTL` | Time-to-live in seconds for run output file list cache entries | `10800` | | `MAX_CACHE_SIZE_MB` | Maximum cache size in megabytes | `50` | +## Rate limiting + +| Variable | Description | Default | +|----------|-------------|---------| +| `RATELIMIT_ENABLED` | Enable per route rate limiting. Auto on when LOCAL_MODE is off. | - | +| `RATELIMIT_DEFAULT` | Default per key rate limit applied to all routes | `120/minute` | +| `RATELIMIT_LOGIN` | Rate limit for POST /auth/login/password | `5/minute;20/hour` | +| `RATELIMIT_EXPENSIVE` | Rate limit for task queueing routes (plots, statistics). | `60/minute;600/hour` | +| `TRUST_CLOUDFLARE_IP` | Trust the CF-Connecting-IP header as the client IP for rate limiting. Only enable when the app sits behind a Cloudflare tunnel. | `false` | + +## Email + +| Variable | Description | Default | +|----------|-------------|---------| +| `SMTP_HOST` | SMTP server hostname (enables email notifications when set) | - | +| `SMTP_PORT` | SMTP server port | `587` | +| `SMTP_USERNAME` | SMTP authentication username | - | +| `SMTP_PASSWORD` | SMTP authentication password | - | +| `SMTP_USE_TLS` | Use TLS/STARTTLS for SMTP connection | `true` | +| `SMTP_FROM_ADDRESS` | Sender email address for notifications | `noreply@pypsa-app.local` | + ## Development | Variable | Description | Default | diff --git a/docs/desktop-build-and-distribute.md b/docs/desktop-build-and-distribute.md new file mode 100644 index 00000000..9455ae80 --- /dev/null +++ b/docs/desktop-build-and-distribute.md @@ -0,0 +1,657 @@ +# Desktop Build and Distribute + +Quick reference for producing and shipping pypsa-desktop installers. +For architecture decisions and phase history, see `docs/desktop-distribution.md`. + +--- + +## Overview + +| Target | Status | Package | Offline install | +|--------|--------|---------|----------------| +| Windows 10/11 x64 | Implemented | NSIS `.exe` installer | Yes — wheels bundled | +| macOS 12+ (arm64 / x64) | Implemented | `.dmg` or `.app` zip | Yes — wheels bundled | +| Linux x64 (Ubuntu 22.04+) | Partial | `.deb` or tarball | No — PyPI on first launch | + +All three targets share the same Wails control plane (`desktop/`) and the same two +Python sidecars (pypsa-app on `:8765`, snakedispatch on `:8766`). Data is stored in the +platform config directory: + +| Platform | Data directory | +|----------|---------------| +| Windows | `%APPDATA%\pypsa-desktop\` | +| macOS | `~/Library/Application Support/pypsa-desktop/` | +| Linux | `~/.config/pypsa-desktop/` | + +--- + +## Developer prerequisites + +**All platforms** + +| Tool | Install | +|------|---------| +| Go 1.23+ | https://go.dev/dl/ | +| Wails v2 CLI | `go install github.com/wailsapp/wails/v2/cmd/wails@latest` | +| pnpm | `npm i -g pnpm` | +| uv | `curl -LsSf https://astral.sh/uv/install.sh \| sh` | + +**macOS build machine (can build Windows, macOS, and Linux targets)** + +```bash +# Xcode command-line tools (codesign, hdiutil, xcrun) +xcode-select --install + +# Cross-compiler for Windows/amd64 targets +brew install mingw-w64 + +# NSIS installer builder (used automatically by wails build -nsis) +brew install makensis + +# Optional: nicer DMG layout +brew install create-dmg +``` + +**Windows build machine only** (alternative to macOS cross-build) + +| Tool | Install | +|------|---------| +| NSIS 3.x | `winget install NSIS.NSIS` | +| Windows SDK (signtool) | Visual Studio installer → "Desktop development with C++" | + +**Linux build machine only** (Ubuntu 22.04) + +```bash +sudo apt install build-essential libwebkit2gtk-4.1-dev libgtk-3-dev +``` + +--- + +## Step 1 — Build the SvelteKit frontend (all platforms) + +The built static files are packaged into the Python wheel, so this must run first. + +```bash +cd frontend/app +pnpm install +pnpm run build +# Output → src/pypsa_app/backend/static/app/ +``` + +--- + +## Windows + +> All steps below can be run from **macOS** (the cross-compile path) or from a +> Windows machine. macOS is the recommended primary build environment. + +### Step 2W — Build the Python wheels + +```bash +# Run in each repo root (macOS or Windows): +cd && uv build # → dist/pypsa_app-X.Y.Z-py3-none-any.whl +cd && uv build # → dist/snakedispatch-X.Y.Z-py3-none-any.whl +``` + +### Step 3W — Collect dependency wheels + +`uv` does not expose a `pip download` subcommand. Use `uv export` to get a +pinned requirements list, filter environment markers for the win_amd64 target, +then download with `python3 -m pip download`. + +```bash +# ── pypsa-app ───────────────────────────────────────────────────────────────── + +# 1. Export pinned deps (no hashes, no -e lines) +uv export --format requirements-txt --no-hashes --package pypsa-app \ + | grep -v '^#\|^-e\|^$' > /tmp/pypsa-app-requirements.txt + +# 2. Filter markers for win_amd64 (removes uvloop which has no Windows wheel, +# keeps colorama/tzdata/greenlet which are Windows-only or amd64-conditional) +python3 - << 'EOF' +import re, sys +win_reqs = [] +for line in open('/tmp/pypsa-app-requirements.txt'): + s = line.strip() + if not s or s.startswith('#'): continue + if ' ; ' in s: + spec, marker = s.split(' ; ', 1) + spec, marker = spec.strip(), marker.strip() + if 'sys_platform == "win32"' in marker or "sys_platform == 'win32'" in marker: + win_reqs.append(spec) # Windows-only: include + elif "sys_platform != 'win32'" in marker or 'sys_platform != "win32"' in marker: + pass # non-Windows only: skip + elif 'AMD64' in marker or 'amd64' in marker: + win_reqs.append(spec) # AMD64-conditional: include + else: + win_reqs.append(spec) # no OS condition: include + else: + win_reqs.append(s) +open('/tmp/pypsa-app-win-requirements.txt', 'w').write('\n'.join(win_reqs) + '\n') +EOF + +# 3. Copy the app wheel; download all deps as win_amd64/cp313 wheels +cp /dist/pypsa_app-*.whl desktop/build/windows/wheels/pypsa-app/ +python3 -m pip download \ + -r /tmp/pypsa-app-win-requirements.txt \ + --platform win_amd64 --python-version 313 --implementation cp --abi cp313 \ + --only-binary :all: \ + -d desktop/build/windows/wheels/pypsa-app/ + +# ── snakedispatch ───────────────────────────────────────────────────────────── + +# Repeat for snakedispatch (same filtering logic, fewer deps) +cd +uv export --format requirements-txt --no-hashes \ + | grep -v '^#\|^-e\|^$' > /tmp/snakedispatch-requirements.txt +# Apply the same Python marker-filter script (swap filenames), then: +cp /dist/snakedispatch-*.whl desktop/build/windows/wheels/snakedispatch/ +python3 -m pip download \ + -r /tmp/snakedispatch-win-requirements.txt \ + --platform win_amd64 --python-version 313 --implementation cp --abi cp313 \ + --only-binary :all: \ + -d desktop/build/windows/wheels/snakedispatch/ +``` + +> **Why `python3 -m pip download` and not `pip download --platform` directly?** +> pip evaluates environment markers against the *host* platform, not the target. +> Exporting the pinned list with `uv export` and filtering markers manually avoids +> pulling `uvloop` (which has no Windows wheel) while correctly including +> `colorama`, `tzdata`, and `greenlet`. + +### Step 4W — Get uv.exe + +```bash +curl -L https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip \ + -o /tmp/uv-windows.zip +unzip /tmp/uv-windows.zip uv.exe -d desktop/build/windows/ +``` + +### Step 5W — Cross-compile the Wails binary and build the NSIS installer + +With `mingw-w64` and `makensis` installed (see prerequisites), a single command +builds the exe and runs the NSIS installer script automatically: + +```bash +cd desktop +GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \ + wails build -platform windows/amd64 -nsis +# Produces: +# build/bin/pypsa-desktop.exe +# build/bin/pypsa-desktop-setup-v0.1.0-amd64.exe (~250 MB) +# build/windows/installer/wails_tools.nsh (auto-generated, gitignored) +``` + +> **On Windows** (without the cross-compile env vars): +> ```powershell +> cd desktop +> wails build -platform windows/amd64 -nsis +> ``` +> If NSIS is not in PATH, `wails build -nsis` will skip the installer step. +> Run makensis manually in that case: +> ```powershell +> cd build\windows\installer +> makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\pypsa-desktop.exe project.nsi +> ``` + +### Step 7W — Sign (optional) + +Without signing, SmartScreen shows an "Unknown Publisher" warning. For close-client +distribution this is acceptable. For wider release use a CA-issued OV/EV certificate. + +```powershell +# Sign the exe and installer with signtool +signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 ` + /f cert.pfx /p $env:CERT_PASSWORD ` + desktop\build\bin\pypsa-desktop.exe + +signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 ` + /f cert.pfx /p $env:CERT_PASSWORD ` + desktop\build\bin\pypsa-desktop-setup-v0.1.0-amd64.exe +``` + +See `docs/desktop-distribution.md` → "Code signing" for how to generate a self-signed +cert for internal distribution. + +The NSIS signing hooks are pre-wired in `project.nsi` (commented out) — uncomment the +`!finalize` / `!uninstfinalize` lines to sign automatically during `makensis`. + +### Step 8W — Distribute + +Ship the single file: + +``` +pypsa-desktop-setup-v0.1.0-amd64.exe (~300–500 MB) +``` + +No companion files needed. The installer is self-contained. + +| Path | Contents | Uninstall behaviour | +|------|----------|---------------------| +| `C:\Program Files\pypsa-desktop\` | Wails exe, uv.exe, wheels | Removed | +| `%APPDATA%\pypsa-desktop\` | SQLite DB, venvs, logs, run data | **Preserved** | + +### Releasing a new version + +1. Bump all three version fields in lockstep (see [Versioning](#versioning)). +2. Rebuild the wheels for both apps (steps 2W–3W). +3. Rebuild the Wails binary (step 5W). +4. Rebuild the installer (step 6W). +5. Ship the new `.exe`. Users run it over the existing install — no data migration needed. + +--- + +## macOS + +> **Status**: Implemented and tested on M4 (arm64) and Intel x86_64. Two separate +> DMGs are produced — one per architecture — each containing a **universal binary** +> (arm64 + x86_64) with architecture-specific uv and wheels bundled inside. +> First-launch setup is fully offline. WKWebView is built into macOS 12+; no extra +> runtime dependencies. +> +> **Python version**: arm64 bundle uses Python 3.13; x86_64 bundle also uses Python +> 3.13 (Bottleneck 1.6.0 is built from source as a universal2 wheel). The binary +> auto-detects the correct Python version from the wheel filenames at runtime via +> `inferPythonVersion()` in `setup.go`. +> +> **Minimum macOS**: arm64 — macOS 11+; x86_64 — macOS 14+ (numpy/scipy require it). + +### Step 2M — Build the Python wheels + +```bash +cd && uv build # → dist/pypsa_app-X.Y.Z-py3-none-any.whl +cd && uv build # → dist/snakedispatch-X.Y.Z-py3-none-any.whl +``` + +### Step 3M — Collect dependency wheels + +`uv pip download` does not exist in uv ≤ 0.9.x. Resolve via native install then +`pip download` the pinned set cross-platform: + +```bash +# ── arm64 (Apple Silicon) ────────────────────────────────────────────────── +uv venv /tmp/pypsa-collect --python 3.13 --seed + +# Resolve arm64 deps via native install, then freeze to pin exact versions +uv venv /tmp/resolve --python 3.13 --seed +/tmp/resolve/bin/pip install pypsa-app --find-links /dist -q +/tmp/resolve/bin/pip freeze | grep -v pypsa.app > /tmp/pypsa-deps.txt +/tmp/resolve/bin/pip freeze | grep -v snakedispatch > /tmp/snkd-deps.txt + +# Download pinned wheels for arm64 +/tmp/pypsa-collect/bin/pip download \ + -r /tmp/pypsa-deps.txt \ + --dest desktop/build/darwin/wheels/pypsa-app \ + --python-version 3.13 --platform macosx_11_0_arm64 --only-binary :all: +cp /dist/pypsa_app-*.whl desktop/build/darwin/wheels/pypsa-app/ + +/tmp/pypsa-collect/bin/pip download \ + -r /tmp/snkd-deps.txt \ + --dest desktop/build/darwin/wheels/snakedispatch \ + --python-version 3.13 --platform macosx_11_0_arm64 --only-binary :all: +cp /dist/snakedispatch-*.whl desktop/build/darwin/wheels/snakedispatch/ + +# ── x86_64 (Intel Mac) ──────────────────────────────────────────────────── +# Bottleneck 1.6.0 has no PyPI x86_64 macOS wheel for Python 3.13. +# Build it from source using the system Python 3.13 universal2 binary via Rosetta: +arch -x86_64 /Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13 \ + -m venv /tmp/pypsa-x86-build +arch -x86_64 /tmp/pypsa-x86-build/bin/pip wheel "Bottleneck==1.6.0" \ + --wheel-dir /tmp/bottleneck-x86-wheel -q +# → bottleneck-1.6.0-cp313-cp313-macosx_10_13_universal2.whl + +# Resolve for x86_64 (Bottleneck as an extra find-links source) +/tmp/pypsa-collect/bin/pip download \ + -r /tmp/pypsa-deps.txt \ + --find-links /tmp/bottleneck-x86-wheel \ + --dest desktop/build/darwin-x86/wheels/pypsa-app \ + --python-version 3.13 --platform macosx_14_0_x86_64 --only-binary :all: +cp /dist/pypsa_app-*.whl desktop/build/darwin-x86/wheels/pypsa-app/ +cp /tmp/bottleneck-x86-wheel/bottleneck-*.whl desktop/build/darwin-x86/wheels/pypsa-app/ + +/tmp/pypsa-collect/bin/pip download \ + -r /tmp/snkd-deps.txt \ + --dest desktop/build/darwin-x86/wheels/snakedispatch \ + --python-version 3.13 --platform macosx_14_0_x86_64 --only-binary :all: +cp /dist/snakedispatch-*.whl desktop/build/darwin-x86/wheels/snakedispatch/ +``` + +Collected wheels are gitignored (`*.whl`). See `desktop/build/darwin/.gitignore`. + +### Step 4M — Get uv binaries + +```bash +# arm64 (from the build machine) +cp $(which uv) desktop/build/darwin/uv + +# x86_64 (download from GitHub releases — match current uv version) +UV_VERSION=$(uv --version | awk '{print $2}') +curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-apple-darwin.tar.gz" \ + | tar -xzO uv-x86_64-apple-darwin/uv > desktop/build/darwin-x86/uv +chmod +x desktop/build/darwin-x86/uv +``` + +### Step 5M — Build the universal Wails binary + +A single universal binary (arm64 + x86_64) is shared between both DMGs: + +```bash +cd desktop +wails build -platform darwin/universal +# Output: build/bin/pypsa-desktop.app (universal binary ~20 MB, self-signed) +``` + +### Step 6M — Assemble bundles + +```bash +# arm64 bundle — uses the .app produced by wails build directly +ARM64_APP=desktop/build/bin/pypsa-desktop.app/Contents/MacOS +cp desktop/build/darwin/uv "$ARM64_APP/uv" +cp -r desktop/build/darwin/wheels "$ARM64_APP/wheels" +chmod +x "$ARM64_APP/uv" + +# x86_64 bundle — copy the app then inject x86_64 assets +X86_APP=desktop/build/bin/pypsa-desktop-x86_64.app +cp -r desktop/build/bin/pypsa-desktop.app "$X86_APP" +X86_MACOS="$X86_APP/Contents/MacOS" +cp desktop/build/darwin-x86/uv "$X86_MACOS/uv" +cp -r desktop/build/darwin-x86/wheels "$X86_MACOS/wheels" +chmod +x "$X86_MACOS/uv" +``` + +`setup.go`'s `inferPythonVersion()` reads the `cpXYZ` tag from wheel filenames to +pick the right Python version automatically — no separate config file needed. + +After injection, `bundledWheelsDir()` finds the wheels at `/wheels` +and first-launch setup runs fully offline on both architectures. + +### Step 7M — Package as DMGs + +```bash +# arm64 DMG +create-dmg \ + --volname "pypsa-desktop" \ + --window-pos 200 120 --window-size 600 400 \ + --icon-size 100 \ + --icon "pypsa-desktop.app" 175 190 \ + --hide-extension "pypsa-desktop.app" \ + --app-drop-link 425 190 \ + --no-internet-enable \ + "desktop/build/bin/pypsa-desktop-v0.1.0-arm64.dmg" \ + "desktop/build/bin/pypsa-desktop.app" +# Output: ~260 MB (universal binary + arm64 wheels) + +# x86_64 DMG +create-dmg \ + --volname "pypsa-desktop" \ + --window-pos 200 120 --window-size 600 400 \ + --icon-size 100 \ + --icon "pypsa-desktop.app" 175 190 \ + --hide-extension "pypsa-desktop.app" \ + --app-drop-link 425 190 \ + --no-internet-enable \ + "desktop/build/bin/pypsa-desktop-v0.1.0-x86_64.dmg" \ + "desktop/build/bin/pypsa-desktop-x86_64.app" +# Output: ~380 MB (universal binary + x86_64 wheels, polars-runtime-32 is larger) +``` + +### Step 7M (alt) — Ship a zipped .app + +```bash +cd desktop/build/bin +zip -r pypsa-desktop-0.1.0-arm64.zip pypsa-desktop.app +zip -r pypsa-desktop-0.1.0-x86_64.zip pypsa-desktop-x86_64.app +``` + +### Step 8M — Sign and notarize (optional) + +Without signing, Gatekeeper shows "cannot be opened because it is from an unidentified +developer." For close clients this is workable — instruct users to right-click → Open +on first launch, or run: + +```bash +xattr -dr com.apple.quarantine /Applications/pypsa-desktop.app +``` + +For wider distribution, sign and notarize with an Apple Developer ID ($99/year): + +**1. Create an entitlements file** (`desktop/build/darwin/entitlements.plist`): + +```xml + + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + com.apple.security.network.client + + + +``` + +**2. Sign the app bundle** (sign bundled binaries first, then the outer bundle): + +```bash +codesign --sign "Developer ID Application: Your Name (TEAMID)" \ + desktop/build/bin/pypsa-desktop.app/Contents/MacOS/uv + +codesign --deep --sign "Developer ID Application: Your Name (TEAMID)" \ + --options runtime \ + --entitlements desktop/build/darwin/entitlements.plist \ + desktop/build/bin/pypsa-desktop.app +``` + +**3. Sign the DMG:** + +```bash +codesign --sign "Developer ID Application: Your Name (TEAMID)" \ + desktop/build/bin/pypsa-desktop-v0.1.0-arm64.dmg +``` + +**4. Submit for notarization and staple:** + +```bash +xcrun notarytool submit desktop/build/bin/pypsa-desktop-v0.1.0-arm64.dmg \ + --apple-id your@apple.id \ + --password "$APP_SPECIFIC_PASSWORD" \ + --team-id TEAMID \ + --wait + +xcrun stapler staple desktop/build/bin/pypsa-desktop-v0.1.0-arm64.dmg +``` + +> **App-specific password**: generate at appleid.apple.com → Sign-In and Security → +> App-Specific Passwords. + +### Step 9M — Distribute + +Ship the DMG matching the recipient's Mac: +- `pypsa-desktop-v0.1.0-arm64.dmg` — Apple Silicon (M1/M2/M3/M4) — requires macOS 11+ +- `pypsa-desktop-v0.1.0-x86_64.dmg` — Intel Mac — requires **macOS 14** (Sonoma) + +No companion tools needed. uv and all Python packages are bundled. Python 3.13 is +downloaded by uv on first launch (~30 s), then packages install from bundled wheels +(~2–3 min). + +### macOS-specific behaviour + +| Feature | macOS | +|---------|-------| +| System tray | Not implemented — closing the window quits the app | +| About dialog | Implemented — tray menu → About (same as Windows) | +| Crash toast | Not implemented — error shown in app window | +| Offline install | Yes — uv + arch-specific wheels inside `Contents/MacOS/` | +| Minimum macOS | arm64: 11+; x86_64: 14+ (numpy/scipy require it) | +| Gatekeeper | Right-click → Open on first launch, or `xattr -dr com.apple.quarantine ` | +| Sidecar shutdown | SIGTERM → SIGKILL after 5 s | + +--- + +## Linux + +> **Status**: The Wails binary builds and runs on Linux. First-launch setup downloads +> Python packages from PyPI (internet required). Offline wheel bundling is not yet +> implemented — see [Future: offline bundle](#future-offline-bundle-on-linux). + +### Runtime dependencies (end-user machine) + +```bash +# Ubuntu 22.04 / Debian 12+ +sudo apt install libwebkit2gtk-4.1-0 libgtk-3-0 + +# Ubuntu 20.04 / Debian 11 +sudo apt install libwebkit2gtk-4.0-37 libgtk-3-0 +``` + +`uv` must be in PATH for the first-launch venv setup: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +# or: pip install uv --user +``` + +### Step 2L — Build the Python wheels + +```bash +cd && uv build # → dist/pypsa_app-X.Y.Z-py3-none-any.whl +cd && uv build # → dist/snakedispatch-X.Y.Z-py3-none-any.whl +``` + +### Step 3L — Build the Wails binary + +```bash +cd desktop +wails build --target linux/amd64 +# Output: build/bin/pypsa-desktop (ELF binary, ~10–20 MB) +``` + +### Step 4L — Package as .deb (Ubuntu/Debian) + +Create the package tree: + +``` +pypsa-desktop_0.1.0_amd64/ +├── DEBIAN/ +│ └── control +└── usr/ + ├── bin/ + │ └── pypsa-desktop ← symlink to ../lib/pypsa-desktop/pypsa-desktop + └── lib/ + └── pypsa-desktop/ + └── pypsa-desktop ← Wails binary +``` + +`DEBIAN/control`: + +``` +Package: pypsa-desktop +Version: 0.1.0 +Architecture: amd64 +Maintainer: mikoding +Depends: libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37, libgtk-3-0, uv +Description: pypsa-desktop + Network planning and dispatch optimisation desktop application. + First launch requires internet access to install Python dependencies. +``` + +Build and install: + +```bash +# Create the symlink +mkdir -p pypsa-desktop_0.1.0_amd64/usr/bin +ln -s ../lib/pypsa-desktop/pypsa-desktop \ + pypsa-desktop_0.1.0_amd64/usr/bin/pypsa-desktop + +# Copy the binary +mkdir -p pypsa-desktop_0.1.0_amd64/usr/lib/pypsa-desktop +cp desktop/build/bin/pypsa-desktop \ + pypsa-desktop_0.1.0_amd64/usr/lib/pypsa-desktop/ + +dpkg-deb --build pypsa-desktop_0.1.0_amd64 +# → pypsa-desktop_0.1.0_amd64.deb (~15 MB without bundled wheels) +``` + +Install on the user's machine: + +```bash +sudo apt install ./pypsa-desktop_0.1.0_amd64.deb +pypsa-desktop # first launch downloads Python deps (~2–3 min) +``` + +### Step 4L (alt) — Package as tarball (distro-agnostic) + +```bash +mkdir pypsa-desktop-0.1.0-linux-x64 +cp desktop/build/bin/pypsa-desktop pypsa-desktop-0.1.0-linux-x64/ +tar czf pypsa-desktop-0.1.0-linux-x64.tar.gz pypsa-desktop-0.1.0-linux-x64/ +``` + +Include a README instructing users to install the runtime deps listed above. + +### Platform feature comparison + +| Feature | Linux | macOS | Windows | +|---------|-------|-------|---------| +| System tray | No — closing quits | No — closing quits | Yes — hides to tray | +| About dialog | No | Yes — tray menu | Yes — tray menu | +| Crash notification | No — in-app error | No — in-app error | Windows toast | +| Runtime deps | libwebkit2gtk, libgtk | None (WKWebView built-in) | None (EdgeWebView2 built-in) | +| Offline install | Not yet | Yes (bundled uv + wheels) | Yes (bundled wheels) | +| Data directory | `~/.config/pypsa-desktop/` | `~/Library/Application Support/pypsa-desktop/` | `%APPDATA%\pypsa-desktop\` | +| Sidecar shutdown | SIGTERM → SIGKILL | SIGTERM → SIGKILL | CTRL_BREAK → Kill | +| Package format | `.deb` or tarball | `.dmg` or `.app` zip | NSIS `.exe` installer | + +### Future: offline bundle on Linux + +To avoid the first-launch PyPI download, two code changes and one packaging change are +needed: + +1. **`desktop/setup.go`** — extend `bundledUVPath()` and `bundledWheelsDir()`: + ```go + case "linux": + return "/usr/lib/pypsa-desktop/uv" // bundledUVPath + case "linux": + return "/usr/lib/pypsa-desktop/wheels" // bundledWheelsDir + ``` + +2. **Wheel collection** — download Linux wheels alongside the binary: + ```bash + uv pip download pypsa-app \ + --find-links dist/ \ + --output-dir desktop/build/linux/wheels/pypsa-app \ + --python-version 3.13 --platform manylinux_2_17_x86_64 --only-binary :all: + ``` + +3. **`.deb` package** — add bundled uv and wheels under `usr/lib/pypsa-desktop/` and + remove the `uv` entry from `Depends:`. + +--- + +## Versioning + +Bump these three files in lockstep for every release: + +| File | Field | +|------|-------| +| `desktop/versions.yaml` | `pypsa-app` and `snakedispatch` | +| `desktop/wails.json` | `info.productVersion` | +| `desktop/build/windows/installer/project.nsi` | `!define INFO_PRODUCTVERSION` | + +The Windows installer output filename picks up the version automatically: +`pypsa-desktop-setup-v${INFO_PRODUCTVERSION}-${ARCH}.exe`. +Use the same version string in DMG and tarball filenames on macOS/Linux for consistency. + +--- + +## Smoke test checklist + +See `docs/desktop-distribution.md` → "Smoke test checklist (Windows 11 VM)" for the +full pre-release checklist covering installer, first launch, tray, crash recovery, and +uninstaller verification. diff --git a/docs/desktop-distribution.md b/docs/desktop-distribution.md new file mode 100644 index 00000000..0667d27a --- /dev/null +++ b/docs/desktop-distribution.md @@ -0,0 +1,440 @@ +# Desktop Distribution Plan — Windows + +> **Current dev platform**: macOS M4. Windows is the distribution target. +> Daily iteration runs both services manually with `uv` — no Wails involved. +> `wails build` / `wails dev` are only used when testing the Wails shell itself. + +## Goal + +Ship `pypsa-app` + `snakedispatch` as a single Windows desktop application: a native executable the user double-clicks, which manages all background services and opens the UI in an embedded browser window. + +No Docker. No server setup. Close-client distribution only. + +--- + +## Prerequisites (stated, not abstracted) + +These are required on the user's machine and must be documented in the installer and README: + +| Requirement | Why | Install method | +|---|---|---| +| Windows 10 22H2+ or Windows 11 | Edge WebView2 is built-in on these versions | — | +| Git for Windows | snakedispatch clones workflow repos | Bundled optional installer or [git-scm.com](https://git-scm.com/download/win) | +| Pixi | snakedispatch uses Pixi to isolate Snakemake envs | `winget install prefix-dev.pixi` or bundled | +| Internet | Not required — Python packages are bundled in the installer | — | + +**WSL2 note**: Not required for the app itself. Required only if a user's Snakemake workflows use Unix shell commands (`rule: shell: "bash ..."`, GNU coreutils, etc.). Workflow authors are responsible for cross-platform compatibility. If users need Unix-only workflows, they should set up a remote snakedispatch on a Linux/WSL2 machine and configure it as an additional backend via the admin panel. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Wails desktop app (Go + Edge WebView2) │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Control plane (Go) │ │ +│ │ - Prerequisite checker │ │ +│ │ - First-launch setup (uv venv creation) │ │ +│ │ - Process manager (spawn / health / kill) │ │ +│ │ - System tray (start, stop, restart, quit) │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ WebView ──► http://localhost:8765 │ +└─────────────────────────────────────────────────────┘ + │ spawns │ spawns + ▼ ▼ +┌─────────────────┐ ┌──────────────────────┐ +│ pypsa-app │ │ snakedispatch │ +│ FastAPI :8765 │──►│ FastAPI :8766 │ +│ SQLite │ │ local backend │ +│ in-memory tasks│ │ Pixi + Git on PATH │ +└─────────────────┘ └──────────────────────┘ + │ + └── serves built SvelteKit static files +``` + +**pypsa-app** runs in minimal mode: SQLite database, in-memory Celery fallback (no Redis, no PostgreSQL, no separate worker process). + +**snakedispatch** runs with a local backend config written by the Wails app on first launch. `SNAKEDISPATCH_BACKENDS=local=http://localhost:8766` is passed to pypsa-app as an env var at startup. + +--- + +## Development Workflow (macOS / Linux) + +For daily iteration you do **not** need Wails at all. Run both services directly with `uv` and access the UI in a browser. + +### One-time setup + +```bash +# pypsa-app — install deps into the project venv +cd /path/to/pypsa-app +uv sync + +# snakedispatch — install deps into its project venv +cd /path/to/snakedispatch +uv sync +``` + +### Run services manually + +**Terminal 1 — pypsa-app** (from `pypsa-app` repo root) +```bash +uv run pypsa-app serve --host 127.0.0.1 --port 8000 --data-dir ./data +``` + +**Terminal 2 — snakedispatch** (from `snakedispatch` repo root) +```bash +uv run uvicorn app.main:app --host 127.0.0.1 --port 8001 +``` + +**Terminal 3 — SvelteKit dev server** (hot reload; skip if testing static build) +```bash +cd frontend/app +pnpm run dev +# UI at http://localhost:5173 (API proxied to :8000) +``` + +Open `http://localhost:8000` (static build) or `http://localhost:5173` (dev server with hot reload). + +> **Note**: dev ports (8000/8001) differ from the Wails production ports (8765/8766). This is intentional — running both at the same time won't conflict. + +> **Version discrepancy between `uv run` and `wails dev`**: `setuptools_scm` freezes the version number at install time by counting git commits since the last tag (e.g. `v0.1.0a1.dev78`). The project `.venv` (used by `uv run`) and the Wails-managed venv (`~/Library/Application Support/pypsa-desktop/venvs/pypsa-app`) are installed independently, so they show different `devN` numbers if installed at different commits. The running code is identical — the number is cosmetic. To sync the project venv to the current commit: +> ```bash +> uv sync --reinstall-package pypsa-app +> ``` + +### After changing frontend code + +```bash +# Rebuild static files into src/pypsa_app/backend/static/app/ +cd frontend/app && pnpm run build +# Then restart pypsa-app (Terminal 1) to serve the new files +``` + +### When to use `wails dev` + +Only when you are working on the **Wails shell** itself (splash screen, startup sequence, system tray). It is not needed for pypsa-app or frontend changes. + +```bash +cd desktop +wails dev +``` + +`wails dev` will detect the local `pypsa-app` source, install it into a managed venv under `~/Library/Application Support/pypsa-desktop/`, and spawn both services. Because no sentinel file is written in dev mode (`setup.go`), it reinstalls `pypsa-app` from local source on every launch using `uv pip install --reinstall-package pypsa-app`. + +> **Stale venv from a previous run?** If an old venv exists from before this behaviour was introduced, delete the sentinel to force a clean reinstall: +> ```bash +> rm ~/Library/Application\ Support/pypsa-desktop/venvs/setup_complete +> ``` + +--- + +## Project Structure + +A new `desktop/` directory inside `pypsa-app`: + +``` +pypsa-app/ +└── desktop/ + ├── main.go # Wails entry point + ├── app.go # Control plane logic + ├── process.go # Subprocess management + ├── setup.go # First-launch warm-up / uv setup + ├── prereqs.go # Prerequisite detection + ├── versions.yaml # Pinned versions for pypsa-app and snakedispatch + ├── wails.json + ├── go.mod + ├── frontend/ # Wails loading/status UI (Svelte, minimal) + │ └── src/ + │ └── App.svelte # Splash screen, warm-up progress, error states + └── build/ + ├── windows/ + │ ├── icon.ico + │ ├── uv.exe # Bundled uv binary (gitignored, downloaded manually) + │ ├── wheels/ + │ │ ├── pypsa-app/ # win_amd64 wheels (gitignored, collected manually) + │ │ └── snakedispatch/ # win_amd64 wheels (gitignored, collected manually) + │ └── installer/ + │ └── project.nsi # NSIS script (tracked in git) +``` + +The Wails `frontend/` here is only the **loading/status shell** (splash screen, setup progress bar, error messages). Once pypsa-app is healthy, the WebView navigates to `http://localhost:8765` and this shell is replaced by the full pypsa-app SvelteKit UI. + +--- + +## Data Layout on Windows + +All runtime data is stored under `%APPDATA%\pypsa-desktop\`: + +``` +%APPDATA%\pypsa-desktop\ +├── venvs\ +│ ├── pypsa-app\ # uv-managed Python venv +│ └── snakedispatch\ # uv-managed Python venv +├── data\ # pypsa-app data (SQLite DB, uploaded networks) +├── snakedispatch\ # snakedispatch job data (job dirs, snkmt.db files) +├── config\ +│ └── snakedispatch.yaml # Written by Wails on first launch +└── logs\ + ├── pypsa-app.log + └── snakedispatch.log +``` + +--- + +## Startup Sequence + +``` +1. Wails app launches +2. Show splash screen +3. Check prerequisites + ├── Git in PATH? → warn if missing, show install link + └── Pixi in PATH? → warn if missing, show install link +4. Check venvs exist + └── Missing? → run first-launch setup (see below) +5. Write snakedispatch.yaml (idempotent) +6. Spawn snakedispatch on :8766 +7. Spawn pypsa-app on :8765 +8. Poll /health on both until ready (timeout: 60s) +9. Navigate WebView to http://localhost:8765 +``` + +**First-launch warm-up** (runs once, ~30–60s on first use): +``` +uv venv %APPDATA%\pypsa-desktop\venvs\pypsa-app +uv pip install --python ...venvs\pypsa-app + +uv venv %APPDATA%\pypsa-desktop\venvs\snakedispatch +uv pip install --python ...venvs\snakedispatch +``` + +Progress is streamed to the splash screen so the user sees what's happening. On subsequent launches this step is skipped entirely via a sentinel file (`venvs/setup_complete`). + +**Sentinel behaviour differs by mode:** +- **Production** (bundled wheels): sentinel written after first install → setup skipped on every subsequent launch. +- **Dev** (local source, no bundled wheels): sentinel is never written → `pypsa-app` is reinstalled from local source on every launch (`uv pip install --reinstall-package pypsa-app`). Deps are left untouched so restarts stay fast. + +--- + +## Python Distribution Strategy + +Both apps are shipped as **pre-built wheels bundled in the installer**. Bundle size of 300–500 MB is acceptable for v1. + +- No internet required after installer runs. +- Controlled versions pinned in `desktop/versions.yaml`. +- uv installs from local paths during first-launch warm-up: `uv pip install pypsa_app-X.Y.Z-py3-none-any.whl` +- The installer places wheels in `%PROGRAMFILES%\pypsa-desktop\wheels\`. +- Wheels are built manually on a Windows machine and committed alongside the installer script. No CI pipeline in v1. + +> **Future:** If bundle size becomes a concern, switch to PyPI download during warm-up (deferred install). The warm-up UI already handles progress display, so the transition is a one-line change in `setup.go`. + +For updates: ship a new installer. No in-app update mechanism in v1. + +### Rebuilding the pypsa-app wheel (Windows distribution only) + +**You do not need this for daily development** — see the Development Workflow section for the faster manual `uv` approach. + +Build the wheel when you are ready to produce a new Windows installer. The SvelteKit frontend must be compiled first; `wails build` does **not** do this (the Wails binary only contains the splash screen in `desktop/frontend/`). + +```bash +# 1. Build the SvelteKit app +# Output goes directly to src/pypsa_app/backend/static/app/ +cd frontend/app +pnpm run build + +# 2. Build the Python wheel +# Packages static files via MANIFEST.in, output in dist/ +cd ../.. +uv build +``` + +The resulting `dist/pypsa_app-X.Y.Z-py3-none-any.whl` is the file to drop into the installer's `wheels/` directory. + +--- + +## Installer + +Built with **NSIS** (Wails' recommended Windows installer toolchain). The `.nsi` script lives in `desktop/build/windows/installer/project.nsi`. + +The installer bundles: +- `pypsa-desktop.exe` (the Wails binary) +- `uv.exe` (placed at `%PROGRAMFILES%\pypsa-desktop\uv.exe`) +- Pre-built `.whl` files for both Python apps and their dependencies (placed under `%PROGRAMFILES%\pypsa-desktop\wheels\`) + +At the end of installation the NSIS script checks for Git and Pixi using `where.exe` and shows a message box listing any that are missing, with install instructions. + +**Built locally**: Installer is built manually on a developer Windows machine using NSIS. No GitHub Actions pipeline in v1. + +**Code signing**: Required to avoid Windows SmartScreen "unknown publisher" block. Use a code signing certificate for production releases. + +### Building the installer + +The full installer can be built from **macOS** (recommended) or from Windows. +See `docs/desktop-build-and-distribute.md` → "Windows" for the complete step-by-step. + +Short form (macOS with `brew install mingw-w64 makensis`): + +```bash +# 1. Build frontend + Python wheels +cd frontend/app && pnpm run build +cd && uv build +cd && uv build + +# 2. Collect win_amd64/cp313 wheels via uv export + pip download +# (see desktop-build-and-distribute.md Step 3W for the full marker-filter script) + +# 3. Download uv.exe +curl -L https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip \ + -o /tmp/uv-windows.zip && unzip /tmp/uv-windows.zip uv.exe -d desktop/build/windows/ + +# 4. Build exe + NSIS installer in one command +cd desktop +GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \ + wails build -platform windows/amd64 -nsis +# Output: desktop/build/bin/pypsa-desktop-setup-v0.1.0-amd64.exe (~250 MB) +``` + +> **Wheel collection note**: `uv` has no `pip download` subcommand. Use `uv export +> --format requirements-txt` to get a pinned list, filter environment markers for +> win_amd64 (removes `uvloop`, keeps `colorama`/`tzdata`), then use +> `python3 -m pip download --platform win_amd64 --only-binary :all:`. +> The `wheels/` directories are gitignored. Total installer size is ~250 MB. + +--- + +## Implementation Phases + +### Phase 0 — Validation spike (do this first, ~2 days) + +Before writing any Wails code, validate the runtime stack on a real Windows machine or VM. + +- [ ] Run `pypsa-app serve` in minimal mode (SQLite, no Redis) on Windows natively +- [ ] Run `snakedispatch` with local backend on Windows, submit a simple Pixi-based workflow +- [ ] Confirm Pixi installs and runs workflows on Windows without WSL2 +- [ ] Confirm the pypsa-app ↔ snakedispatch integration works end-to-end + +If Snakemake workflows fail on Windows during this spike, document which types fail and add them to the prerequisite warning (WSL2 needed for Unix-shell workflows). + +### Phase 1 — Wails shell (~3 days) + +- [x] `desktop/` project: `wails init`, configure Go module +- [x] Minimal splash screen frontend (SvelteKit or plain HTML): status text, progress bar, error view +- [x] WebView navigation: splash → app URL once healthy +- [x] System tray: Show window, Quit +- [x] Basic port conflict detection (log and abort with message if :8765/:8766 are taken) + +### Phase 2 — Process management (~3 days) + +- [x] `process.go`: spawn subprocess, capture stdout/stderr to rotating log file, detect exit +- [x] Health polling: GET /health on both services, retry with backoff, timeout +- [x] Graceful shutdown: SIGTERM to children, wait up to 5s, then SIGKILL +- [x] Crash recovery: restart a dead sidecar up to 3 times before surfacing error to user +- [x] Pass correct env vars to pypsa-app: + - `DATABASE_URL=sqlite:///%APPDATA%\pypsa-desktop\data\pypsa-app.db` + - `DATA_DIR=%APPDATA%\pypsa-desktop\data` + - `SNAKEDISPATCH_BACKENDS=local=http://localhost:8766` + - `BASE_URL=http://localhost:8765` + +### Phase 3 — First-launch setup (~3 days) + +- [x] `prereqs.go`: check Git and Pixi in PATH, return structured results +- [x] `setup.go`: detect venvs, run uv to create and populate them from bundled wheels, stream progress to frontend +- [x] Splash screen shows per-step warm-up progress ("Installing pypsa-app... 47%") +- [x] On error: show actionable message (e.g. "Git not found. Install from git-scm.com and restart.") +- [x] Mark setup complete in a sentinel file so it's skipped on subsequent launches + +### Phase 4 — snakedispatch config generation (~1 day) + +- [x] Write `snakedispatch.yaml` to `%APPDATA%\pypsa-desktop\config\` on first launch +- [x] Local backend config: `scratch_dir`, `pixi_path` (resolved from PATH), `poll_interval: 5` +- [x] Pass config path to snakedispatch via env var or CLI arg + +### Phase 5 — Installer (~2 days) + +- [x] `desktop/build/windows/installer/project.nsi`: NSIS script (extends Wails scaffold) +- [x] Bundle exe, uv.exe, wheels directory (built manually, no CI) — script references `../uv.exe` and `../wheels/` +- [x] Detect Git/Pixi absence at end of installation, show message with install instructions +- [x] Uninstaller removes `%PROGRAMFILES%\pypsa-desktop\` only — user data at `%APPDATA%\pypsa-desktop\` preserved +- [x] Output: `pypsa-desktop-setup-v${VERSION}-amd64.exe` — see "Building the installer" section above + +### Phase 6 — Polish & release (~2 days) + +- [x] Code signing: NSIS hooks pre-wired (commented out in `project.nsi`); see "Code signing" below +- [x] Windows toast notification on fatal crash (`notify_windows.go` via `go-toast`; fires before window is shown) +- [x] "About" systray menu: shows pypsa-app version, snakedispatch version, Python version (`about.go` + `systray_windows.go`) +- [ ] Smoke test on a clean Windows 11 VM — checklist below + +### Code signing + +To avoid the Windows SmartScreen "Unknown Publisher" block, the Wails binary and installer must be signed with a code signing certificate. + +**For internal / close-client distribution (self-signed):** +```powershell +# Generate self-signed cert (run once on the developer machine) +New-SelfSignedCertificate -Type CodeSigning -Subject "CN=pypsa-desktop" ` + -KeyUsage DigitalSignature -FriendlyName "pypsa-desktop" ` + -CertStoreLocation Cert:\CurrentUser\My -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3") + +# Export to PFX +$cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Subject -eq "CN=pypsa-desktop" } +Export-PfxCertificate -Cert $cert -FilePath cert.pfx -Password (Read-Host -AsSecureString) + +# Sign the exe and installer using signtool (Windows SDK) +signtool sign /fd SHA256 /f cert.pfx /p pypsa-desktop.exe +signtool sign /fd SHA256 /f cert.pfx /p pypsa-desktop-setup-v0.1.0-amd64.exe +``` + +Self-signed certs still require the recipient to manually trust the cert on first run. For wider distribution, use a CA-issued OV or EV code signing certificate (DigiCert, Sectigo, etc.). + +**Wiring it into the build:** Uncomment the `!finalize` / `!uninstfinalize` lines in `project.nsi` and set `CERT_PASSWORD`. + +### Smoke test checklist (Windows 11 VM) + +Run these on a **clean Windows 11 install** (no Python, no existing pypsa-desktop) before each release. + +**Installer** +- [ ] Installer runs without UAC prompt errors +- [ ] SmartScreen warning appears (expected without EV cert); user can bypass via "More info → Run anyway" +- [ ] Install completes without errors; shortcut appears on Desktop and Start Menu +- [ ] Prerequisite warning shown if Git or Pixi is absent + +**First launch** +- [ ] Splash screen appears +- [ ] uv downloads Python 3.13 automatically (no Python pre-installed) +- [ ] First-launch setup progress bar advances through both venv installs +- [ ] App navigates to `http://localhost:8767` and the pypsa-app UI loads +- [ ] Runs nav is visible (snakedispatch backend wired up) +- [ ] System tray icon appears with correct tooltip + +**System tray** +- [ ] "Show Window" brings window to foreground +- [ ] "About" shows correct pypsa-app, snakedispatch, and Python versions +- [ ] "Quit" stops both sidecars and exits cleanly + +**Subsequent launch** +- [ ] Warm-up is skipped (sentinel file present); app starts in < 5 s +- [ ] Previously uploaded networks still visible (SQLite preserved) + +**Crash recovery** +- [ ] Kill one sidecar process manually (`taskkill /F /PID `) — app restarts it +- [ ] Kill the sidecar 3 more times — toast notification appears, error shown in UI + +**Uninstaller** +- [ ] Uninstaller removes `C:\Program Files\pypsa-desktop\` completely +- [ ] `%APPDATA%\pypsa-desktop\` is **not** removed (user data preserved) +- [ ] Shortcuts removed from Desktop and Start Menu +- [ ] App no longer appears in "Apps & features" + +--- + +## Decisions + +| # | Decision | +|---|---| +| 1 | **Wheel build**: Built from the developer's macOS machine using cross-compilation (`mingw-w64` + `makensis` via Homebrew). No Windows machine required. Development and iteration happen on macOS M4 using manual `uv` commands — no Wails involved. No GitHub Actions CI in v1. | +| 2 | **Version pinning**: `desktop/versions.yaml` pins both `pypsa-app` and `snakedispatch` versions for each installer release. Go code reads this file at build time. | +| 3 | **Local backend in admin**: The bundled snakedispatch appears as **"local (managed)"** — visible but not editable. Users can freely add additional remote backends via the admin panel. | +| 4 | **Authentication**: No auth providers configured for desktop installs (no OAuth clients, `AUTH_PASSWORD_ENABLED` not set). Single shared instance per machine. Acceptable for v1 close-client distribution. | +| 5 | **Dependency bundle**: Ship pre-built wheels (300–500 MB) in the installer. No internet required after install. If bundle size becomes a concern in a future release, switch to first-launch warm-up via PyPI download — the warm-up UI already supports this flow. | diff --git a/docs/macos-intel-build-notes.md b/docs/macos-intel-build-notes.md new file mode 100644 index 00000000..48d6e215 --- /dev/null +++ b/docs/macos-intel-build-notes.md @@ -0,0 +1,285 @@ +# macOS Intel (x86_64) Build — Obstacles and Solutions + +Build context: M4 (arm64) build machine, targeting Intel Mac coworker on macOS 14. +Date: 2026-05-24. + +--- + +## 1. `uv pip download` does not exist + +**Symptom** + +``` +$ uv pip download pypsa-app --dest wheels/ --platform macosx_14_0_x86_64 +error: unrecognized subcommand 'download' +``` + +**Cause** + +`uv pip download` was not implemented in uv ≤ 0.9.x. The subcommand exists in +newer uv releases, but the version in use (0.9.30) only supports `uv pip install`, +`uv pip compile`, etc. + +**Solution** + +Create a seeded venv to get a vanilla `pip`, then use `pip download` directly: + +```bash +uv venv /tmp/pypsa-collect --python 3.13 --seed +/tmp/pypsa-collect/bin/pip download pypsa-app \ + --dest wheels/pypsa-app \ + --python-version 3.13 --platform macosx_14_0_x86_64 --only-binary :all: +``` + +--- + +## 2. pip resolver fails on dev-version wheels with multiple versions in `--find-links` + +**Symptom** + +``` +ERROR: Cannot install pypsa-app because these package versions have conflicting dependencies. +ERROR: ResolutionImpossible +``` + +**Cause** + +The local `dist/` directory contained multiple dev-build wheels of `pypsa-app` +(e.g. `dev108`, `dev112`, `dev115`). When passed as `--find-links dist/`, pip's +resolver tried to reconcile all of them as candidates, hit a constraint conflict, and +gave up. + +**Solution** + +Stage only the latest wheel in a clean temporary directory before calling +`pip download`: + +```bash +mkdir -p /tmp/find-links/pypsa-app +cp dist/pypsa_app-.whl /tmp/find-links/pypsa-app/ +pip download pypsa-app --find-links /tmp/find-links/pypsa-app ... +``` + +--- + +## 3. pip resolution still too complex for direct cross-platform download + +**Symptom** + +Even with a single wheel in `--find-links`, `pip download pypsa-app` with +`--platform macosx_14_0_x86_64` produced: + +``` +Pip cannot resolve the current dependencies as the dependency graph is too complex +for pip to solve efficiently. +``` + +**Cause** + +`pip download` with `--platform` + a local dev wheel involves resolving transitive +dependencies entirely offline/hypothetically. The pip resolver struggled with the +depth of the pypsa-app dependency tree (86 packages) combined with the cross-platform +constraint. + +**Solution** + +Split the process into two steps: + +1. **Resolve natively** (arm64, same machine) — install into a temp venv and freeze: + + ```bash + uv venv /tmp/resolve --python 3.13 --seed + /tmp/resolve/bin/pip install pypsa-app --find-links /tmp/find-links/pypsa-app -q + /tmp/resolve/bin/pip freeze | grep -v pypsa.app > /tmp/pypsa-deps.txt + ``` + +2. **Download cross-platform** using the pinned requirements (no resolver work needed): + + ```bash + pip download -r /tmp/pypsa-deps.txt \ + --dest wheels/pypsa-app \ + --python-version 3.13 --platform macosx_14_0_x86_64 --only-binary :all: + ``` + +--- + +## 4. Bottleneck 1.6.0 has no macOS x86_64 wheel for Python 3.13 + +**Symptom** + +``` +ERROR: Could not find a version that satisfies the requirement Bottleneck==1.6.0 +ERROR: No matching distribution found for Bottleneck==1.6.0 +``` + +Tested across every macOS x86_64 platform tag (`macosx_10_13_x86_64` through +`macosx_14_0_x86_64`) — all returned the same error. + +**Cause** + +Bottleneck 1.6.0 was published to PyPI with only Linux and macOS **arm64** wheels. +No macOS x86_64 wheel exists for Python 3.13 on PyPI. (Older Bottleneck versions +up to 1.3.8 had macOS x86_64 wheels but only up to Python 3.12.) + +Bottleneck is a hard dependency of `linopy`, which is a hard dependency of `pypsa`. +There is no way to install pypsa-app without it. + +**Solution** + +Build Bottleneck from source using the **Rosetta 2** translation layer and the +system Python 3.13 **universal2** binary (installed from python.org): + +```bash +# Verify the system Python is universal2 (works on both arm64 and x86_64) +file /Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13 +# → Mach-O universal binary with 2 architectures: [x86_64] [arm64] + +# Create an x86_64 venv by forcing x86_64 mode via Rosetta +arch -x86_64 /Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13 \ + -m venv /tmp/pypsa-x86-build + +# Build Bottleneck as a wheel (compiles C extension targeting x86_64) +arch -x86_64 /tmp/pypsa-x86-build/bin/pip wheel "Bottleneck==1.6.0" \ + --wheel-dir /tmp/bottleneck-x86-wheel -q +# → bottleneck-1.6.0-cp313-cp313-macosx_10_13_universal2.whl +``` + +The resulting wheel is tagged `universal2` (contains both architectures), which is +compatible with both Intel and Apple Silicon Macs. It is then provided as an extra +`--find-links` source when calling `pip download`: + +```bash +pip download -r /tmp/pypsa-deps.txt \ + --find-links /tmp/bottleneck-x86-wheel \ + --dest wheels/pypsa-app \ + --python-version 3.13 --platform macosx_14_0_x86_64 --only-binary :all: +cp /tmp/bottleneck-x86-wheel/bottleneck-*.whl wheels/pypsa-app/ +``` + +**Prerequisites**: Rosetta 2 must be installed (`softwareupdate --install-rosetta`). +The python.org Python installer (not Homebrew) provides a universal2 binary. + +--- + +## 5. pypsa-app requires Python ≥ 3.13; Bottleneck ≥ 1.6.0 only has x86_64 Python ≤ 3.12 + +**Symptom** + +Attempting to use Python 3.12 as a workaround for Bottleneck: + +``` +ERROR: Package 'pypsa-app' requires a different Python: 3.12.10 not in '>=3.13' +``` + +**Cause** + +`pypsa-app/pyproject.toml` has `requires-python = ">=3.13"`. Downgrading to Python +3.12 is not an option without also dropping that constraint. + +This is what drove the Rosetta build approach in obstacle 4 — there is no clean way to +get Bottleneck for Python 3.13 + macOS x86_64 without building from source. + +--- + +## 6. `netcdf4`, `numpy`, `scipy` require macOS 13–14 for x86_64 + +**Symptom** + +`pip download netcdf4 --platform macosx_11_0_x86_64 --only-binary :all:` returned +no matches. + +**Cause** + +Recent releases of these scientific packages stopped shipping macOS x86_64 wheels for +older OS versions. The minimum macOS for x86_64 wheels: + +| Package | Minimum macOS (x86_64) | +|---------|------------------------| +| netcdf4 | 13.0 | +| numpy | 14.0 | +| scipy | 14.0 | +| pyproj | 13.0 | + +Specifying `--platform macosx_14_0_x86_64` causes pip to accept wheels built for any +macOS ≥ 10.x targeting x86_64 (backward-compatible), so all packages resolve. + +**Impact on end users** + +Intel Mac coworkers need **macOS 14 (Sonoma)** to run the x86_64 bundle. Sonoma +supports Intel Macs from 2018 onwards. + +--- + +## 7. Wails build cross-compilation flag is `-platform`, not `--target` + +**Symptom** + +``` +$ wails build --target darwin/universal +ERROR: flag provided but not defined: -target +``` + +**Cause** + +Wails v2's CLI uses single-dash long flags. The flag is `-platform`, not `--target`. + +**Solution** + +```bash +wails build -platform darwin/universal +``` + +This produces a universal binary (arm64 + x86_64) in a single `.app` bundle. The +same universal binary is used in both the arm64 and x86_64 DMGs; only the bundled +`uv` binary and `wheels/` directory differ. + +--- + +## 8. `create-dmg` AppleScript error leaves writable intermediate DMG + +**Symptom** + +``` +execution error: Finder got an error: Can't set item "pypsa-desktop.app" of +disk "dmg.xxxxxx" to {175, 190}. (-10006) +``` + +The final compressed `.dmg` was never produced; only the intermediate +`rw.xxxxx.pypsa-desktop.dmg` file remained. + +**Cause** + +`create-dmg` uses AppleScript to position icons in the Finder window of a writable +DMG before compressing it. On some macOS configurations (e.g. when Finder is not +available to the shell process) this Finder IPC call fails. The tool exits after the +error, skipping the `hdiutil convert` step. + +**Solution** + +Skip `create-dmg` and convert the writable intermediate DMG directly: + +```bash +hdiutil convert rw.xxxxx.pypsa-desktop.dmg \ + -format UDZO \ + -o pypsa-desktop-v0.1.0-x86_64.dmg \ + -ov +``` + +The resulting DMG lacks the Finder background/icon layout but is functionally +identical. For cosmetic DMGs with drag-to-Applications layout, the `create-dmg` +call generally works when run interactively (not inside a headless CI environment). + +--- + +## Summary + +| Obstacle | Root cause | Solution | +|----------|-----------|----------| +| `uv pip download` missing | uv ≤ 0.9.x has no download subcommand | Use `uv venv --seed` to get pip, then `pip download` | +| Multiple dev wheels confuse resolver | Old builds in `dist/` | Stage only latest wheel in clean temp dir | +| Resolver too complex for cross-platform | Pip can't resolve deep graph cross-platform | Resolve natively first, freeze, then `pip download -r requirements.txt` | +| Bottleneck has no x86_64 Python 3.13 wheel | PyPI gap — arm64-only for 1.6.x | Build from source via Rosetta (`arch -x86_64`) | +| pypsa-app requires Python ≥ 3.13 | Package constraint | Drives the Rosetta approach; Python 3.12 is not an option | +| netcdf4/numpy/scipy require macOS 14 | New wheel builds target newer SDKs | Use `--platform macosx_14_0_x86_64`; require macOS 14 on Intel | +| Wrong Wails flag | `-platform` not `--target` | `wails build -platform darwin/universal` | +| `create-dmg` AppleScript error | Headless Finder IPC failure | `hdiutil convert rw.xxx.dmg -format UDZO -o final.dmg` | diff --git a/frontend/app/package-lock.json b/frontend/app/package-lock.json index 9a5ffbc9..328548c8 100644 --- a/frontend/app/package-lock.json +++ b/frontend/app/package-lock.json @@ -1035,9 +1035,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1052,9 +1049,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1069,9 +1063,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1086,9 +1077,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1103,9 +1091,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1120,9 +1105,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1137,9 +1119,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1154,9 +1133,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1171,9 +1147,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1188,9 +1161,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1205,9 +1175,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1222,9 +1189,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1239,9 +1203,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1581,9 +1542,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1601,9 +1559,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1621,9 +1576,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1641,9 +1593,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2619,9 +2568,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2643,9 +2589,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2667,9 +2610,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2691,9 +2631,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/app/pnpm-lock.yaml b/frontend/app/pnpm-lock.yaml new file mode 100644 index 00000000..675041e4 --- /dev/null +++ b/frontend/app/pnpm-lock.yaml @@ -0,0 +1,2339 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@deck.gl/core': + specifier: 9.3.2 + version: 9.3.2 + '@deck.gl/layers': + specifier: 9.3.2 + version: 9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.2)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))) + '@tailwindcss/typography': + specifier: 0.5.19 + version: 0.5.19(tailwindcss@4.3.0) + '@tanstack/svelte-table': + specifier: 9.0.0-alpha.10 + version: 9.0.0-alpha.10(svelte@5.55.5) + dompurify: + specifier: 3.4.2 + version: 3.4.2 + elkjs: + specifier: 0.11.1 + version: 0.11.1 + gridstack: + specifier: 12.6.0 + version: 12.6.0 + js-yaml: + specifier: 4.1.1 + version: 4.1.1 + maplibre-gl: + specifier: 5.24.0 + version: 5.24.0 + marked: + specifier: 17.0.6 + version: 17.0.6 + mode-watcher: + specifier: 1.1.0 + version: 1.1.0(svelte@5.55.5) + plotly.js-dist: + specifier: 3.5.1 + version: 3.5.1 + svelte-sonner: + specifier: 1.1.1 + version: 1.1.1(svelte@5.55.5) + devDependencies: + '@internationalized/date': + specifier: 3.12.1 + version: 3.12.1 + '@lucide/svelte': + specifier: 0.561.0 + version: 0.561.0(svelte@5.55.5) + '@sveltejs/adapter-static': + specifier: 3.0.10 + version: 3.0.10(@sveltejs/kit@2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0))) + '@sveltejs/kit': + specifier: 2.59.1 + version: 2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)) + '@sveltejs/vite-plugin-svelte': + specifier: 6.2.4 + version: 6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)) + '@tailwindcss/vite': + specifier: 4.3.0 + version: 4.3.0(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)) + '@tanstack/table-core': + specifier: 8.21.3 + version: 8.21.3 + '@types/js-yaml': + specifier: 4.0.9 + version: 4.0.9 + '@types/node': + specifier: 25.7.0 + version: 25.7.0 + bits-ui: + specifier: 2.18.1 + version: 2.18.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5) + clsx: + specifier: 2.1.1 + version: 2.1.1 + svelte: + specifier: 5.55.5 + version: 5.55.5 + svelte-check: + specifier: 4.4.8 + version: 4.4.8(picomatch@4.0.4)(svelte@5.55.5)(typescript@5.9.3) + tailwind-merge: + specifier: 3.6.0 + version: 3.6.0 + tailwind-variants: + specifier: 3.2.2 + version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.0) + tailwindcss: + specifier: 4.3.0 + version: 4.3.0 + tw-animate-css: + specifier: 1.4.0 + version: 1.4.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 7.3.3 + version: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0) + +packages: + + '@deck.gl/core@9.3.2': + resolution: {integrity: sha512-32Va3np0Zdlz/LBNtDWCs4EkKqdHmXcbGmVp4+7i1Cpdza8y8CFmJs2VPOmSX1fwHvNCGkAZV/SFZOfDb2INsg==} + + '@deck.gl/layers@9.3.2': + resolution: {integrity: sha512-TeVfhQ/cQU1oTlTn16mCp7268d1uBJ6dwfgmKXThe2TzW9hql3iJaxbYTKg2phDg5YSiGmeEOpXbeBh59jyUcA==} + peerDependencies: + '@deck.gl/core': ~9.3.0 + '@loaders.gl/core': ^4.4.1 + '@luma.gl/core': ~9.3.3 + '@luma.gl/engine': ~9.3.3 + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@internationalized/date@3.12.1': + resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@loaders.gl/core@4.4.2': + resolution: {integrity: sha512-DZmsTwxdKh3q+mS1vSOW2EXFgwxZ4nIBte4H5g6e4VyQoQ6jAOkk0M6V+Asgy/eqjGTNjhfBA1HIkyBl0A9hcA==} + + '@loaders.gl/images@4.4.2': + resolution: {integrity: sha512-b+1keNvPlyLniWtX4ZaThz2dF2aohi8Q+OEsDF2hJNZYyZJOqP9b/72UhlVk+inxTJfTLRBNARs2TJ2ssBlelg==} + peerDependencies: + '@loaders.gl/core': ~4.4.0 + + '@loaders.gl/loader-utils@4.4.2': + resolution: {integrity: sha512-kqwBbyRC7rrQVsnJyKeoaig9hxaa5oj91OKqWm27HPuVn4q2dD67SEhiG0ND62eRp0tLY6jTqEcI5kDzHBZ6MA==} + + '@loaders.gl/schema-utils@4.4.2': + resolution: {integrity: sha512-yYYRD/POBEO72rhIyLASrqKUUhfIOQuFk/fgInN6Td2qvFgsHbo5UaCM4sTqVUWwNxNvXDQi8ezpbnCa/yi+OQ==} + peerDependencies: + '@loaders.gl/core': ~4.4.0 + + '@loaders.gl/schema@4.4.2': + resolution: {integrity: sha512-mJTZehTHIFl8ed+03nebuPAMnLP8Yp00DKTzCnKT2HNy/uV4+Sw+GrGIuhPHGU8tdQmtBXRURGM2ZxUAxMfGKg==} + + '@loaders.gl/worker-utils@4.4.2': + resolution: {integrity: sha512-oiZ0SoC1QKrOkhYPlVZ6Q06CtmuFRyZw2rwzmT08ZyaGtOArIJHDjlhxzwWiv+6fdws47Ub5uIGsdI1Ab1xYsA==} + peerDependencies: + '@loaders.gl/core': ~4.4.0 + + '@lucide/svelte@0.561.0': + resolution: {integrity: sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==} + peerDependencies: + svelte: ^5 + + '@luma.gl/core@9.3.3': + resolution: {integrity: sha512-jCFm2htvrVpcXIy85TBTF1ROgMfknKnfw2OH+Vydr41hiCFd6nqr79gM3f2uhaNkal0BghFNqF3qDioKiUWtew==} + + '@luma.gl/engine@9.3.3': + resolution: {integrity: sha512-StmMTzUcUlpKMU3wvWU48A6OQyphptD9zVGBsSkK6iHIBdtBKlOcmqRkyfvRouo8JHtlrnoJDHLVKhxorwhGAg==} + peerDependencies: + '@luma.gl/core': ~9.3.0 + '@luma.gl/shadertools': ~9.3.0 + + '@luma.gl/shadertools@9.3.3': + resolution: {integrity: sha512-4ZfG4/Utix951vqyiG/JIx+Eg+GMNwOxgr/07/i0gf7bK1gJZIEQ5BxVcDw4MCQfdoVlGPGzl0cQKbdqBvaCAQ==} + peerDependencies: + '@luma.gl/core': ~9.3.0 + + '@luma.gl/webgl@9.3.3': + resolution: {integrity: sha512-X+aavdP5o6VFHSA0es9gKZTT145jfcFbhKJt/gwJrptnKNoIW4+Y37ZEpCo1AzAnr+FQCxjgcM2kOCpoWMfSVA==} + peerDependencies: + '@luma.gl/core': ~9.3.0 + + '@mapbox/jsonlint-lines-primitives@2.0.2': + resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} + engines: {node: '>= 0.6'} + + '@mapbox/point-geometry@1.1.0': + resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} + + '@mapbox/tiny-sdf@2.2.0': + resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==} + + '@mapbox/unitbezier@0.0.1': + resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} + + '@mapbox/vector-tile@2.0.4': + resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==} + + '@mapbox/whoots-js@3.1.0': + resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} + engines: {node: '>=6.0.0'} + + '@maplibre/geojson-vt@5.0.4': + resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==} + + '@maplibre/geojson-vt@6.1.0': + resolution: {integrity: sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==} + + '@maplibre/maplibre-gl-style-spec@24.8.5': + resolution: {integrity: sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==} + hasBin: true + + '@maplibre/mlt@1.1.9': + resolution: {integrity: sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==} + + '@maplibre/vt-pbf@4.3.0': + resolution: {integrity: sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==} + + '@math.gl/core@4.1.0': + resolution: {integrity: sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==} + + '@math.gl/polygon@4.1.0': + resolution: {integrity: sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==} + + '@math.gl/sun@4.1.0': + resolution: {integrity: sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==} + + '@math.gl/types@4.1.0': + resolution: {integrity: sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==} + + '@math.gl/web-mercator@4.1.0': + resolution: {integrity: sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@probe.gl/env@4.1.1': + resolution: {integrity: sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==} + + '@probe.gl/log@4.1.1': + resolution: {integrity: sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==} + + '@probe.gl/stats@4.1.1': + resolution: {integrity: sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sveltejs/acorn-typescript@1.0.10': + resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-static@3.0.10': + resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.59.1': + resolution: {integrity: sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 || ^6.0.0 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2': + resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@sveltejs/vite-plugin-svelte@6.2.4': + resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@swc/helpers@0.5.21': + resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/svelte-table@9.0.0-alpha.10': + resolution: {integrity: sha512-H0eAQlpXgK9JYYrgc0cWXVqEJywxkhuYODnVobUZGskFg6J+5PP+7UCjzrY9ftMge6s1hwb2Ipq3u4wPYJz7HA==} + engines: {node: '>=12'} + peerDependencies: + svelte: ^5.0.0-next + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@tanstack/table-core@9.0.0-alpha.10': + resolution: {integrity: sha512-f2kEGGL+d+I7evkhU926cID2MyH7nPI8acAPcwpaAR1DrgTNStAMp3NS+tgMDyrYtc8zd+RyTxC8m+NBhHhFmA==} + engines: {node: '>=12'} + + '@types/command-line-args@5.2.3': + resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==} + + '@types/command-line-usage@5.0.4': + resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==} + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@types/node@25.7.0': + resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==} + + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + + '@types/supercluster@7.1.3': + resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + apache-arrow@21.1.0: + resolution: {integrity: sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==} + hasBin: true + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + array-back@6.2.3: + resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==} + engines: {node: '>=12.17'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bits-ui@2.18.1: + resolution: {integrity: sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==} + engines: {node: '>=20'} + peerDependencies: + '@internationalized/date': ^3.8.1 + svelte: ^5.33.0 + + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + command-line-args@6.0.2: + resolution: {integrity: sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==} + engines: {node: '>=12.20'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + + command-line-usage@7.0.4: + resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==} + engines: {node: '>=12.20.0'} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + + dompurify@3.4.2: + resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==} + + earcut@2.2.4: + resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} + + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.2.9: + resolution: {integrity: sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + find-replace@5.0.2: + resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==} + engines: {node: '>=14'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + gridstack@12.6.0: + resolution: {integrity: sha512-dUrqsormSybFn/2P4Dz8AgprftKD5e/IiV7UmC0XLQU+G+/WtkAeFiCSNLoAGhPDXoJ/O61Xtj3gljY/Ds83yQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-bignum@0.0.3: + resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} + engines: {node: '>=0.8'} + + json-stringify-pretty-compact@4.0.0: + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} + + kdbush@4.1.0: + resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + maplibre-gl@5.24.0: + resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==} + engines: {node: '>=16.14.0', npm: '>=8.1.0'} + + marked@17.0.6: + resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} + engines: {node: '>= 20'} + hasBin: true + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mjolnir.js@3.0.0: + resolution: {integrity: sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA==} + + mode-watcher@1.1.0: + resolution: {integrity: sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==} + peerDependencies: + svelte: ^5.27.0 + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + murmurhash-js@1.0.0: + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + pbf@4.0.1: + resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + plotly.js-dist@3.5.1: + resolution: {integrity: sha512-ZalyufrVSy5R7y1FWCJZSkm/ACw/5fid6taqUkR8HZfHPBkUDAdxSg1+Rjhe5kB/euLqq/PF4Ug9kD1NeLOmxQ==} + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + potpack@2.1.0: + resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} + + protocol-buffers-schema@3.6.1: + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + runed@0.23.4: + resolution: {integrity: sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==} + peerDependencies: + svelte: ^5.7.0 + + runed@0.25.0: + resolution: {integrity: sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==} + peerDependencies: + svelte: ^5.7.0 + + runed@0.28.0: + resolution: {integrity: sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==} + peerDependencies: + svelte: ^5.7.0 + + runed@0.35.1: + resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} + peerDependencies: + '@sveltejs/kit': ^2.21.0 + svelte: ^5.7.0 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + supercluster@8.0.1: + resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + svelte-check@4.4.8: + resolution: {integrity: sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte-sonner@1.1.1: + resolution: {integrity: sha512-5cd3p7wa4cq0NsqslMwdlPb7x1JglEZ/GKrLePWNr5bCxR1nagAVrY01FRFrXfUGs41miLt3C327+8XJo5BzZw==} + peerDependencies: + svelte: ^5.0.0 + + svelte-toolbelt@0.10.6: + resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.30.2 + + svelte-toolbelt@0.7.1: + resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.0.0 + + svelte@5.55.5: + resolution: {integrity: sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==} + engines: {node: '>=18'} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + table-layout@4.1.1: + resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} + engines: {node: '>=12.17'} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwind-variants@3.2.2: + resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} + engines: {node: '>=16.x', pnpm: '>=7.x'} + peerDependencies: + tailwind-merge: '>=3.0.0' + tailwindcss: '*' + peerDependenciesMeta: + tailwind-merge: + optional: true + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyqueue@3.0.0: + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + typical@7.3.0: + resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} + engines: {node: '>=12.17'} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici-types@7.21.0: + resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + wordwrapjs@5.1.1: + resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} + engines: {node: '>=12.17'} + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@deck.gl/core@9.3.2': + dependencies: + '@loaders.gl/core': 4.4.2 + '@loaders.gl/images': 4.4.2(@loaders.gl/core@4.4.2) + '@luma.gl/core': 9.3.3 + '@luma.gl/engine': 9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)) + '@luma.gl/shadertools': 9.3.3(@luma.gl/core@9.3.3) + '@luma.gl/webgl': 9.3.3(@luma.gl/core@9.3.3) + '@math.gl/core': 4.1.0 + '@math.gl/sun': 4.1.0 + '@math.gl/types': 4.1.0 + '@math.gl/web-mercator': 4.1.0 + '@probe.gl/env': 4.1.1 + '@probe.gl/log': 4.1.1 + '@probe.gl/stats': 4.1.1 + '@types/offscreencanvas': 2019.7.3 + gl-matrix: 3.4.4 + mjolnir.js: 3.0.0 + transitivePeerDependencies: + - '@75lb/nature' + + '@deck.gl/layers@9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.2)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))': + dependencies: + '@deck.gl/core': 9.3.2 + '@loaders.gl/core': 4.4.2 + '@loaders.gl/images': 4.4.2(@loaders.gl/core@4.4.2) + '@loaders.gl/schema': 4.4.2 + '@luma.gl/core': 9.3.3 + '@luma.gl/engine': 9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)) + '@luma.gl/shadertools': 9.3.3(@luma.gl/core@9.3.3) + '@mapbox/tiny-sdf': 2.2.0 + '@math.gl/core': 4.1.0 + '@math.gl/polygon': 4.1.0 + '@math.gl/web-mercator': 4.1.0 + earcut: 2.2.4 + transitivePeerDependencies: + - '@75lb/nature' + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@internationalized/date@3.12.1': + dependencies: + '@swc/helpers': 0.5.21 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@loaders.gl/core@4.4.2': + dependencies: + '@loaders.gl/loader-utils': 4.4.2(@loaders.gl/core@4.4.2) + '@loaders.gl/schema': 4.4.2 + '@loaders.gl/schema-utils': 4.4.2(@loaders.gl/core@4.4.2) + '@loaders.gl/worker-utils': 4.4.2(@loaders.gl/core@4.4.2) + '@probe.gl/log': 4.1.1 + transitivePeerDependencies: + - '@75lb/nature' + + '@loaders.gl/images@4.4.2(@loaders.gl/core@4.4.2)': + dependencies: + '@loaders.gl/core': 4.4.2 + '@loaders.gl/loader-utils': 4.4.2(@loaders.gl/core@4.4.2) + transitivePeerDependencies: + - '@75lb/nature' + + '@loaders.gl/loader-utils@4.4.2(@loaders.gl/core@4.4.2)': + dependencies: + '@loaders.gl/schema': 4.4.2 + '@loaders.gl/worker-utils': 4.4.2(@loaders.gl/core@4.4.2) + '@probe.gl/log': 4.1.1 + '@probe.gl/stats': 4.1.1 + transitivePeerDependencies: + - '@75lb/nature' + - '@loaders.gl/core' + + '@loaders.gl/schema-utils@4.4.2(@loaders.gl/core@4.4.2)': + dependencies: + '@loaders.gl/core': 4.4.2 + '@loaders.gl/schema': 4.4.2 + '@types/geojson': 7946.0.16 + apache-arrow: 21.1.0 + transitivePeerDependencies: + - '@75lb/nature' + + '@loaders.gl/schema@4.4.2': + dependencies: + '@types/geojson': 7946.0.16 + apache-arrow: 21.1.0 + transitivePeerDependencies: + - '@75lb/nature' + + '@loaders.gl/worker-utils@4.4.2(@loaders.gl/core@4.4.2)': + dependencies: + '@loaders.gl/core': 4.4.2 + + '@lucide/svelte@0.561.0(svelte@5.55.5)': + dependencies: + svelte: 5.55.5 + + '@luma.gl/core@9.3.3': + dependencies: + '@math.gl/types': 4.1.0 + '@probe.gl/env': 4.1.1 + '@probe.gl/log': 4.1.1 + '@probe.gl/stats': 4.1.1 + '@types/offscreencanvas': 2019.7.3 + + '@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))': + dependencies: + '@luma.gl/core': 9.3.3 + '@luma.gl/shadertools': 9.3.3(@luma.gl/core@9.3.3) + '@math.gl/core': 4.1.0 + '@math.gl/types': 4.1.0 + '@probe.gl/log': 4.1.1 + '@probe.gl/stats': 4.1.1 + + '@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)': + dependencies: + '@luma.gl/core': 9.3.3 + '@math.gl/core': 4.1.0 + '@math.gl/types': 4.1.0 + + '@luma.gl/webgl@9.3.3(@luma.gl/core@9.3.3)': + dependencies: + '@luma.gl/core': 9.3.3 + '@math.gl/types': 4.1.0 + '@probe.gl/env': 4.1.1 + + '@mapbox/jsonlint-lines-primitives@2.0.2': {} + + '@mapbox/point-geometry@1.1.0': {} + + '@mapbox/tiny-sdf@2.2.0': {} + + '@mapbox/unitbezier@0.0.1': {} + + '@mapbox/vector-tile@2.0.4': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@types/geojson': 7946.0.16 + pbf: 4.0.1 + + '@mapbox/whoots-js@3.1.0': {} + + '@maplibre/geojson-vt@5.0.4': {} + + '@maplibre/geojson-vt@6.1.0': + dependencies: + kdbush: 4.1.0 + + '@maplibre/maplibre-gl-style-spec@24.8.5': + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/unitbezier': 0.0.1 + json-stringify-pretty-compact: 4.0.0 + minimist: 1.2.8 + quickselect: 3.0.0 + tinyqueue: 3.0.0 + + '@maplibre/mlt@1.1.9': + dependencies: + '@mapbox/point-geometry': 1.1.0 + + '@maplibre/vt-pbf@4.3.0': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@mapbox/vector-tile': 2.0.4 + '@maplibre/geojson-vt': 5.0.4 + '@types/geojson': 7946.0.16 + '@types/supercluster': 7.1.3 + pbf: 4.0.1 + supercluster: 8.0.1 + + '@math.gl/core@4.1.0': + dependencies: + '@math.gl/types': 4.1.0 + + '@math.gl/polygon@4.1.0': + dependencies: + '@math.gl/core': 4.1.0 + + '@math.gl/sun@4.1.0': {} + + '@math.gl/types@4.1.0': {} + + '@math.gl/web-mercator@4.1.0': + dependencies: + '@math.gl/core': 4.1.0 + + '@polka/url@1.0.0-next.29': {} + + '@probe.gl/env@4.1.1': {} + + '@probe.gl/log@4.1.1': + dependencies: + '@probe.gl/env': 4.1.1 + + '@probe.gl/stats@4.1.1': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@sveltejs/acorn-typescript@1.0.10(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))': + dependencies: + '@sveltejs/kit': 2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)) + + '@sveltejs/kit@2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)) + '@types/cookie': 0.6.0 + acorn: 8.16.0 + cookie: 0.6.0 + devalue: 5.8.1 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.1.0 + sirv: 3.0.2 + svelte: 5.55.5 + vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0) + optionalDependencies: + typescript: 5.9.3 + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)) + obug: 2.1.1 + svelte: 5.55.5 + vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0) + + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)) + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.1 + svelte: 5.55.5 + vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0) + vitefu: 1.1.3(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)) + + '@swc/helpers@0.5.21': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/typography@0.5.19(tailwindcss@4.3.0)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0) + + '@tanstack/svelte-table@9.0.0-alpha.10(svelte@5.55.5)': + dependencies: + '@tanstack/table-core': 9.0.0-alpha.10 + svelte: 5.55.5 + + '@tanstack/table-core@8.21.3': {} + + '@tanstack/table-core@9.0.0-alpha.10': {} + + '@types/command-line-args@5.2.3': {} + + '@types/command-line-usage@5.0.4': {} + + '@types/cookie@0.6.0': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/geojson@7946.0.16': {} + + '@types/js-yaml@4.0.9': {} + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@types/node@25.7.0': + dependencies: + undici-types: 7.21.0 + + '@types/offscreencanvas@2019.7.3': {} + + '@types/supercluster@7.1.3': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/trusted-types@2.0.7': {} + + acorn@8.16.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + apache-arrow@21.1.0: + dependencies: + '@swc/helpers': 0.5.21 + '@types/command-line-args': 5.2.3 + '@types/command-line-usage': 5.0.4 + '@types/node': 24.12.4 + command-line-args: 6.0.2 + command-line-usage: 7.0.4 + flatbuffers: 25.9.23 + json-bignum: 0.0.3 + tslib: 2.8.1 + transitivePeerDependencies: + - '@75lb/nature' + + argparse@2.0.1: {} + + aria-query@5.3.1: {} + + array-back@6.2.3: {} + + axobject-query@4.1.0: {} + + bits-ui@2.18.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5): + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/dom': 1.7.6 + '@internationalized/date': 3.12.1 + esm-env: 1.2.2 + runed: 0.35.1(@sveltejs/kit@2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5) + svelte: 5.55.5 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5) + tabbable: 6.4.0 + transitivePeerDependencies: + - '@sveltejs/kit' + + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + command-line-args@6.0.2: + dependencies: + array-back: 6.2.3 + find-replace: 5.0.2 + lodash.camelcase: 4.3.0 + typical: 7.3.0 + + command-line-usage@7.0.4: + dependencies: + array-back: 6.2.3 + chalk-template: 0.4.0 + table-layout: 4.1.1 + typical: 7.3.0 + + cookie@0.6.0: {} + + cssesc@3.0.0: {} + + deepmerge@4.3.1: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devalue@5.8.1: {} + + dompurify@3.4.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + earcut@2.2.4: {} + + earcut@3.0.2: {} + + elkjs@0.11.1: {} + + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esm-env@1.2.2: {} + + esrap@2.2.9: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + find-replace@5.0.2: {} + + flatbuffers@25.9.23: {} + + fsevents@2.3.3: + optional: true + + gl-matrix@3.4.4: {} + + graceful-fs@4.2.11: {} + + gridstack@12.6.0: {} + + has-flag@4.0.0: {} + + inline-style-parser@0.2.7: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + jiti@2.7.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-bignum@0.0.3: {} + + json-stringify-pretty-compact@4.0.0: {} + + kdbush@4.1.0: {} + + kleur@4.1.5: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-character@3.0.0: {} + + lodash.camelcase@4.3.0: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + maplibre-gl@5.24.0: + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/point-geometry': 1.1.0 + '@mapbox/tiny-sdf': 2.2.0 + '@mapbox/unitbezier': 0.0.1 + '@mapbox/vector-tile': 2.0.4 + '@mapbox/whoots-js': 3.1.0 + '@maplibre/geojson-vt': 6.1.0 + '@maplibre/maplibre-gl-style-spec': 24.8.5 + '@maplibre/mlt': 1.1.9 + '@maplibre/vt-pbf': 4.3.0 + '@types/geojson': 7946.0.16 + earcut: 3.0.2 + gl-matrix: 3.4.4 + kdbush: 4.1.0 + murmurhash-js: 1.0.0 + pbf: 4.0.1 + potpack: 2.1.0 + quickselect: 3.0.0 + tinyqueue: 3.0.0 + + marked@17.0.6: {} + + minimist@1.2.8: {} + + mjolnir.js@3.0.0: {} + + mode-watcher@1.1.0(svelte@5.55.5): + dependencies: + runed: 0.25.0(svelte@5.55.5) + svelte: 5.55.5 + svelte-toolbelt: 0.7.1(svelte@5.55.5) + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + murmurhash-js@1.0.0: {} + + nanoid@3.3.12: {} + + obug@2.1.1: {} + + pbf@4.0.1: + dependencies: + resolve-protobuf-schema: 2.1.0 + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + plotly.js-dist@3.5.1: {} + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + potpack@2.1.0: {} + + protocol-buffers-schema@3.6.1: {} + + quickselect@3.0.0: {} + + readdirp@4.1.2: {} + + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.1 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + runed@0.23.4(svelte@5.55.5): + dependencies: + esm-env: 1.2.2 + svelte: 5.55.5 + + runed@0.25.0(svelte@5.55.5): + dependencies: + esm-env: 1.2.2 + svelte: 5.55.5 + + runed@0.28.0(svelte@5.55.5): + dependencies: + esm-env: 1.2.2 + svelte: 5.55.5 + + runed@0.35.1(@sveltejs/kit@2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5): + dependencies: + dequal: 2.0.3 + esm-env: 1.2.2 + lz-string: 1.5.0 + svelte: 5.55.5 + optionalDependencies: + '@sveltejs/kit': 2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)) + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + set-cookie-parser@3.1.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + supercluster@8.0.1: + dependencies: + kdbush: 4.1.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + svelte-check@4.4.8(picomatch@4.0.4)(svelte@5.55.5)(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.55.5 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte-sonner@1.1.1(svelte@5.55.5): + dependencies: + runed: 0.28.0(svelte@5.55.5) + svelte: 5.55.5 + + svelte-toolbelt@0.10.6(@sveltejs/kit@2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5): + dependencies: + clsx: 2.1.1 + runed: 0.35.1(@sveltejs/kit@2.59.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5)(typescript@5.9.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.5) + style-to-object: 1.0.14 + svelte: 5.55.5 + transitivePeerDependencies: + - '@sveltejs/kit' + + svelte-toolbelt@0.7.1(svelte@5.55.5): + dependencies: + clsx: 2.1.1 + runed: 0.23.4(svelte@5.55.5) + style-to-object: 1.0.14 + svelte: 5.55.5 + + svelte@5.55.5: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.1 + esm-env: 1.2.2 + esrap: 2.2.9 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tabbable@6.4.0: {} + + table-layout@4.1.1: + dependencies: + array-back: 6.2.3 + wordwrapjs: 5.1.1 + + tailwind-merge@3.6.0: {} + + tailwind-variants@3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.0): + dependencies: + tailwindcss: 4.3.0 + optionalDependencies: + tailwind-merge: 3.6.0 + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyqueue@3.0.0: {} + + totalist@3.0.1: {} + + tslib@2.8.1: {} + + tw-animate-css@1.4.0: {} + + typescript@5.9.3: {} + + typical@7.3.0: {} + + undici-types@7.16.0: {} + + undici-types@7.21.0: {} + + util-deprecate@1.0.2: {} + + vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.7.0 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + + vitefu@1.1.3(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)): + optionalDependencies: + vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0) + + wordwrapjs@5.1.1: {} + + zimmerframe@1.1.4: {} diff --git a/frontend/app/src/lib/api/client.ts b/frontend/app/src/lib/api/client.ts index e265f8de..f2f583ae 100644 --- a/frontend/app/src/lib/api/client.ts +++ b/frontend/app/src/lib/api/client.ts @@ -24,6 +24,7 @@ import type { UserStatsResponse, } from "$lib/types.js"; import type { ReportState } from "$lib/stores/reportStore.svelte.js"; +import { toast } from 'svelte-sonner'; const API_BASE = '/api/v1'; @@ -81,6 +82,9 @@ async function request(endpoint: string, options: RequestInit = {}, cancellat if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { window.location.href = '/login'; } + } else if (endpoint !== '/auth/me') { + // /auth/me returns expected 400/401 responses handled by authStore — don't toast + toast.error(message); } throw err; @@ -107,15 +111,29 @@ async function request(endpoint: string, options: RequestInit = {}, cancellat } // Auth API +export interface AuthProviderInfo { + id: string; + name: string; + type: string; + login_url?: string; + icon?: string; +} + export const auth = { async me(): Promise { return request('/auth/me'); }, + async providers(): Promise<{ providers: AuthProviderInfo[] }> { + return request<{ providers: AuthProviderInfo[] }>('/auth/providers'); + }, + async passwordLogin(email: string, password: string): Promise { + return request('/auth/login/password', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); + }, logout(): void { window.location.href = '/api/v1/auth/logout'; - }, - login(): void { - window.location.href = '/api/v1/auth/login'; } }; @@ -402,6 +420,9 @@ export const runs = { logsUrl(id: string): string { return `${API_BASE}/runs/${id}/logs`; }, + async revealLogs(id: string): Promise { + return request(`/runs/${id}/logs/reveal`, { method: 'POST' }); + }, async listOutputs(id: string): Promise { return request(`/runs/${id}/outputs`); }, diff --git a/frontend/app/src/lib/components/AppSidebar.svelte b/frontend/app/src/lib/components/AppSidebar.svelte index 4c33da76..21ebd1c3 100644 --- a/frontend/app/src/lib/components/AppSidebar.svelte +++ b/frontend/app/src/lib/components/AppSidebar.svelte @@ -2,12 +2,14 @@ import { onMount } from 'svelte'; import { version } from '$lib/api/client.js'; import { authStore } from '$lib/stores/auth.svelte.js'; + import { features } from '$lib/stores/features.svelte.js'; import NavMain from './sidebar/NavMain.svelte'; import NavAdmin from './sidebar/NavAdmin.svelte'; import NavUser from './sidebar/NavUser.svelte'; import * as Sidebar from '$lib/components/ui/sidebar'; import * as Tooltip from '$lib/components/ui/tooltip'; import Badge from '$lib/components/ui/badge/badge.svelte'; + import TriangleAlert from '@lucide/svelte/icons/triangle-alert'; // Version info interface VersionData { @@ -90,6 +92,31 @@ + {#if features.demoMode} +
+
+ + Demo instance +
+

+ This is a public demo instance. Data shown is synthetic and may not be coherent. Uploads + to this instance are disabled. You can host your own instance or run the app in local + mode. + + Learn more + . +

+
+ {/if} + {#if !authStore.loading && authStore.isAuthenticated} diff --git a/frontend/app/src/lib/components/admin/AssignmentList.svelte b/frontend/app/src/lib/components/admin/AssignmentList.svelte index e03357e1..8b3da389 100644 --- a/frontend/app/src/lib/components/admin/AssignmentList.svelte +++ b/frontend/app/src/lib/components/admin/AssignmentList.svelte @@ -70,9 +70,7 @@ {#if candidates.length === 0}

No candidates available.

- {:else if options.length === 0} -

All candidates assigned.

- {:else} + {:else if options.length > 0} {#snippet trigger({ props })} - - Or continue with - - - + + + + {#if errorMsg} + + {/if} + + + + {/if} + + {#if hasCredentials && hasOAuth} + Or continue with + {/if} + + {#if !hasCredentials && hasOAuth} +
+

Login to your account

+
+ {/if} + + {#each oauthProviders as p (p.id)} + {@const Icon = iconFor(p.icon)} + + + + {/each} + +

+ Don't have an account? + + Sign up + +

diff --git a/frontend/app/src/routes/networks/+page.svelte b/frontend/app/src/routes/networks/+page.svelte index 12fea5db..3b5037dc 100644 --- a/frontend/app/src/routes/networks/+page.svelte +++ b/frontend/app/src/routes/networks/+page.svelte @@ -7,7 +7,6 @@ import { formatFileSize, getTagType, getTagColor, saveTablePref, buildOwnerOptions, loadTablePrefs, clampPage } from '$lib/utils.js'; import Network from '@lucide/svelte/icons/network'; import Loader2 from '@lucide/svelte/icons/loader-2'; - import { toast } from 'svelte-sonner'; import * as Dialog from '$lib/components/ui/dialog'; import { Combobox } from '$lib/components/widgets/combobox'; import { Label } from '$lib/components/ui/label'; @@ -16,9 +15,10 @@ import DataTable from '$lib/components/DataTable.svelte'; import { createColumns } from './components/columns.js'; import { authStore } from '$lib/stores/auth.svelte.js'; + import { features } from '$lib/stores/features.svelte.js'; import EmptyState from '$lib/components/EmptyState.svelte'; import { TableSkeleton } from '$lib/components/skeletons'; - import type { Network as NetworkType, User, NetworkUpdate, ApiError, Visibility } from '$lib/types.js'; + import type { Network as NetworkType, User, NetworkUpdate, Visibility } from '$lib/types.js'; import type { FilterCategory } from '$lib/components/widgets/filter-dialog'; import type { FilterAst } from '$lib/filters/ast'; import { emptyAnd, isEmpty as astIsEmpty } from '$lib/filters/ast'; @@ -170,9 +170,7 @@ await updateURL(); return loadNetworks(); } - } catch (err) { - if ((err as ApiError).cancelled) return; - toast.error((err as Error).message); + } catch { } finally { loading = false; } @@ -256,8 +254,7 @@ await networks.delete(networkId, removeFile); } await loadNetworks(); - } catch (err) { - if (!(err as ApiError).cancelled) toast.error((err as Error).message); + } catch { } finally { deletingId = null; deleteTarget = null; @@ -274,8 +271,7 @@ await networks.updateVisibility(networkId, newVisibility); } await loadNetworks(); - } catch (err) { - if (!(err as ApiError).cancelled) toast.error((err as Error).message); + } catch { } finally { updatingVisibilityId = null; } @@ -290,9 +286,7 @@ try { const response = await admin.listUsers(0, 1000); allUsers = response.data; - } catch (err) { - toast.error(`Failed to load users: ${(err as Error).message}`); - } + } catch {} } } @@ -307,8 +301,7 @@ await admin.updateNetwork(editNetwork.id, { user_id: editOwner }); await loadNetworks(); editDialogOpen = false; - } catch (err) { - toast.error(`Failed to update owner: ${(err as Error).message}`); + } catch { } finally { saving = false; } @@ -330,7 +323,9 @@
- + {#if !features.demoMode} + + {/if} {#if viewState === 'loading'} diff --git a/frontend/app/src/routes/networks/[id]/components/ReportGrid.svelte b/frontend/app/src/routes/networks/[id]/components/ReportGrid.svelte index 8ed2732d..a40ec2e8 100644 --- a/frontend/app/src/routes/networks/[id]/components/ReportGrid.svelte +++ b/frontend/app/src/routes/networks/[id]/components/ReportGrid.svelte @@ -13,6 +13,8 @@ import type { NetworkWithFacets } from '../types.js'; import type { GridStack as GridStackType } from 'gridstack'; import Trash2 from '@lucide/svelte/icons/trash-2'; + import LayoutGrid from '@lucide/svelte/icons/layout-grid'; + import EmptyState from '$lib/components/EmptyState.svelte'; import 'gridstack/dist/gridstack.min.css'; interface NetworkFacets { @@ -491,9 +493,11 @@
{#if resolvedCards.length === 0} -
-

No cards yet. Add a plot, map, or note to get started.

-
+ {/if}
{/if} @@ -621,9 +625,4 @@ :global(.grid-stack.grid-stack--hidden) { visibility: hidden; } - - /* C2: Empty state */ - .grid-empty-state { - min-height: 200px; - } diff --git a/frontend/app/src/routes/networks/components/RegisterPathDialog.svelte b/frontend/app/src/routes/networks/components/RegisterPathDialog.svelte index 3474807f..3a012a8b 100644 --- a/frontend/app/src/routes/networks/components/RegisterPathDialog.svelte +++ b/frontend/app/src/routes/networks/components/RegisterPathDialog.svelte @@ -29,8 +29,7 @@ toast.success('Network registered'); onSuccess?.(); open = false; - } catch (err) { - toast.error((err as Error).message); + } catch { } finally { registering = false; } diff --git a/frontend/app/src/routes/networks/components/UploadButton.svelte b/frontend/app/src/routes/networks/components/UploadButton.svelte index 317562e6..6ca1dffc 100644 --- a/frontend/app/src/routes/networks/components/UploadButton.svelte +++ b/frontend/app/src/routes/networks/components/UploadButton.svelte @@ -8,6 +8,7 @@ import { Button } from '$lib/components/ui/button'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import { networks } from '$lib/api/client.js'; + import type { ApiError } from '$lib/types.js'; import { features } from '$lib/stores/features.svelte.js'; import { toast } from 'svelte-sonner'; import UrlImportDialog from './UrlImportDialog.svelte'; @@ -44,7 +45,9 @@ toast.success('Network imported'); onUpload?.(); } catch (err) { - toast.error((err as Error).message); + if (!(err as ApiError).cancelled && !(err as ApiError).status) { + toast.error((err as Error).message); + } } finally { importing = false; input.value = ''; diff --git a/frontend/app/src/routes/networks/components/UrlImportDialog.svelte b/frontend/app/src/routes/networks/components/UrlImportDialog.svelte index fca86645..8c497c0e 100644 --- a/frontend/app/src/routes/networks/components/UrlImportDialog.svelte +++ b/frontend/app/src/routes/networks/components/UrlImportDialog.svelte @@ -3,6 +3,7 @@ import * as Dialog from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; import { networks } from '$lib/api/client.js'; + import type { ApiError } from '$lib/types.js'; import { toast } from 'svelte-sonner'; interface Props { @@ -31,7 +32,9 @@ open = false; url = ''; } catch (err) { - toast.error((err as Error).message); + if (!(err as ApiError).cancelled && !(err as ApiError).status) { + toast.error((err as Error).message); + } } finally { importing = false; } diff --git a/frontend/app/src/routes/runs/+page.svelte b/frontend/app/src/routes/runs/+page.svelte index 75dd5628..97a06bee 100644 --- a/frontend/app/src/routes/runs/+page.svelte +++ b/frontend/app/src/routes/runs/+page.svelte @@ -6,7 +6,7 @@ import { runs } from '$lib/api/client.js'; import { saveTablePref, buildOwnerOptions, loadTablePrefs, clampPage } from '$lib/utils.js'; import { RUN_FINAL_STATUSES } from '$lib/types.js'; - import type { RunSummary, User, BackendPublic, ApiError, Visibility } from '$lib/types.js'; + import type { RunSummary, User, BackendPublic, Visibility } from '$lib/types.js'; import type { FilterCategory } from '$lib/components/widgets/filter-dialog'; import type { FilterAst } from '$lib/filters/ast'; import { emptyAnd, isEmpty as astIsEmpty } from '$lib/filters/ast'; @@ -15,10 +15,10 @@ import DataTable from '$lib/components/DataTable.svelte'; import StatusBadge from './cells/StatusBadge.svelte'; import * as Avatar from '$lib/components/ui/avatar'; + import * as Dialog from '$lib/components/ui/dialog'; import Play from '@lucide/svelte/icons/play'; import Plus from '@lucide/svelte/icons/plus'; import Button from '$lib/components/ui/button/button.svelte'; - import { toast } from 'svelte-sonner'; import { createColumns } from './components/columns.js'; import CreateRunDialog from './components/CreateRunDialog.svelte'; import { authStore } from '$lib/stores/auth.svelte.js'; @@ -34,6 +34,12 @@ let updatingVisibilityId = $state(null); let createOpen = $state(false); + // Confirm dialogs + let cancelDialogOpen = $state(false); + let cancelTarget = $state(null); + let removeDialogOpen = $state(false); + let removeTarget = $state(null); + // Filter categories use the singular field names that match the backend // AST/DSL (e.g. `status`, not `statuses`). The `?q=` URL param carries // the DSL string. @@ -181,9 +187,7 @@ await updateURL(); return loadRuns(silent); } - } catch (err) { - if ((err as ApiError).cancelled) return; - toast.error((err as Error).message); + } catch { } finally { loading = false; } @@ -228,19 +232,24 @@ await loadRuns(); } - async function handleCancel(runId: string) { + function handleCancel(runId: string) { if (cancellingId) return; - if (!confirm('Are you sure you want to cancel this run?')) { - return; - } + cancelTarget = runsList.find((r) => r.id === runId) ?? null; + cancelDialogOpen = true; + } + + async function confirmCancel() { + if (!cancelTarget || cancellingId) return; + const runId = cancelTarget.id; + cancelDialogOpen = false; cancellingId = runId; try { await runs.cancel(runId); await loadRuns(); - } catch (err) { - if (!(err as ApiError).cancelled) toast.error((err as Error).message); + } catch { } finally { cancellingId = null; + cancelTarget = null; } } @@ -249,9 +258,7 @@ const fullRun = await runs.get(run.id); const newRun = await runs.rerun(fullRun); goto(`/runs/${newRun.id}`); - } catch (err) { - if (!(err as ApiError).cancelled) toast.error((err as Error).message); - } + } catch {} } async function handleVisibilityToggle(runId: string, visibility: Visibility) { @@ -260,26 +267,30 @@ try { await runs.updateVisibility(runId, visibility); await loadRuns(true); - } catch (err) { - if (!(err as ApiError).cancelled) toast.error((err as Error).message); + } catch { } finally { updatingVisibilityId = null; } } - async function handleRemove(runId: string) { + function handleRemove(runId: string) { if (removingId) return; - if (!confirm('Are you sure you want to remove this run? This will delete all associated files and cannot be undone.')) { - return; - } + removeTarget = runsList.find((r) => r.id === runId) ?? null; + removeDialogOpen = true; + } + + async function confirmRemove() { + if (!removeTarget || removingId) return; + const runId = removeTarget.id; + removeDialogOpen = false; removingId = runId; try { await runs.remove(runId); await loadRuns(); - } catch (err) { - if (!(err as ApiError).cancelled) toast.error((err as Error).message); + } catch { } finally { removingId = null; + removeTarget = null; } } @@ -338,3 +349,53 @@ loadRuns()} /> + + + + + + Cancel run? + {#if cancelTarget} + + {cancelTarget.workflow} + + {/if} + +
+ The running job will be stopped. This cannot be undone. +
+ + + + +
+
+ + + + + + Remove run? + {#if removeTarget} + + {removeTarget.workflow} + + {/if} + +
+ This will delete all associated files and cannot be undone. +
+ + + + +
+
diff --git a/frontend/app/src/routes/runs/[id]/+page.svelte b/frontend/app/src/routes/runs/[id]/+page.svelte index 16a8ec43..cc038bea 100644 --- a/frontend/app/src/routes/runs/[id]/+page.svelte +++ b/frontend/app/src/routes/runs/[id]/+page.svelte @@ -27,7 +27,6 @@ import RunHeader from '../components/RunHeader.svelte'; import LockedContentPreview from '$lib/components/LockedContentPreview.svelte'; import NotFound from '$lib/components/NotFound.svelte'; - import { toast } from 'svelte-sonner'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; const runId = $derived($page.params.id as string); @@ -185,7 +184,7 @@ const workflowDisplay = $derived.by(() => { stopLogStream(); if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } loadPublicRun(); - } else if (isAuthenticated) { + } else if (isAuthenticated || authStore.authEnabled === false) { // Authenticated mode: full experience run = null; logs = []; @@ -208,12 +207,13 @@ const workflowDisplay = $derived.by(() => { const breadcrumbLabel = $derived.by(() => { if (!displayRun) return '...'; - if (configDisplay) return configDisplay; + if (configDisplay) return `${configDisplay} · ${displayRun.id}`; const repo = workflowDisplay?.split('/').pop() || ''; let label = repo; const ref = displayRun.git_ref || displayRun.git_sha?.slice(0, 8) || ''; if (ref) label += `@${ref}`; - return label || displayRun.id.slice(0, 8); + if (label) return `${label} · ${displayRun.id}`; + return displayRun.id; }); $effect(() => { @@ -248,10 +248,7 @@ const workflowDisplay = $derived.by(() => { loading = true; try { run = await runs.get(runId); - } catch (err) { - if (!(err as ApiError).cancelled && (err as ApiError).status !== 404 && (err as ApiError).status !== 422) { - toast.error((err as Error).message); - } + } catch { } finally { loading = false; } @@ -335,8 +332,7 @@ const workflowDisplay = $derived.by(() => { setBusy(true); try { await action(); - } catch (err) { - if (!(err as ApiError).cancelled) toast.error((err as Error).message); + } catch { } finally { setBusy(false); } @@ -377,6 +373,11 @@ const workflowDisplay = $derived.by(() => { } }); } + + async function handleRevealLogs(event: MouseEvent) { + event.stopPropagation(); + await runs.revealLogs(runId); + }
@@ -482,7 +483,7 @@ const workflowDisplay = $derived.by(() => { - {#if run.status !== 'PENDING'} + {#if run.status !== 'PENDING' && run.status !== 'SETUP'} {/if} @@ -523,27 +524,36 @@ const workflowDisplay = $derived.by(() => {
- + + + +
{#if logsOpen}
{ {/if} {:else} - {#each logs as line, i} + {#each logs as line}
{line}
{/each} {/if} diff --git a/frontend/app/src/routes/runs/components/CreateRunDialog.svelte b/frontend/app/src/routes/runs/components/CreateRunDialog.svelte index 66ebe53f..548393ea 100644 --- a/frontend/app/src/routes/runs/components/CreateRunDialog.svelte +++ b/frontend/app/src/routes/runs/components/CreateRunDialog.svelte @@ -12,7 +12,7 @@ import Button from '$lib/components/ui/button/button.svelte'; import Loader2 from '@lucide/svelte/icons/loader-2'; import Info from '@lucide/svelte/icons/info'; - import type { BackendPublic, ApiError } from '$lib/types.js'; + import type { BackendPublic } from '$lib/types.js'; const FIELD_PATHS = [ 'workflow', @@ -73,8 +73,7 @@ try { backends = await runs.backends(); if (!backend_id) autoSelectBackend(); - } catch (err) { - if (!(err as ApiError).cancelled) toast.error((err as Error).message); + } catch { } finally { backendsLoaded = true; } @@ -159,8 +158,7 @@ reset(); open = false; onCreated?.(); - } catch (err) { - toast.error((err as Error).message); + } catch { } finally { submitting = false; } diff --git a/pyproject.toml b/pyproject.toml index 89fa1960..baeec9f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,13 +21,13 @@ dependencies = [ "fastapi>=0.136", "starlette>=1.0,<2", "uvicorn[standard]>=0.46", - "pydantic>=2.13,<3", + "pydantic[email]>=2.13,<3", "pydantic-settings>=2.14,<3", "sqlalchemy>=2.0,<3", "pypsa>=1.2,<2", "pandas>=3.0,<4", "click>=8.3", - "authlib>=1.7,<2", + "authlib>=1.7.2,<2", "itsdangerous>=2.2", "httpx>=0.28,<1", "python-multipart>=0.0.27", @@ -35,6 +35,7 @@ dependencies = [ "jinja2>=3.1,<4", "markupsafe>=3.0,<4", "platformdirs>=4", + "slowapi>=0.1.9,<0.2", ] [project.optional-dependencies] @@ -62,7 +63,7 @@ Repository = "https://github.com/PyPSA/pypsa-app" Issues = "https://github.com/PyPSA/pypsa-app/issues" [build-system] -requires = ["setuptools>=64", "setuptools_scm>=8"] +requires = ["setuptools>=82.0.1", "setuptools_scm>=8"] build-backend = "setuptools.build_meta" [tool.setuptools] diff --git a/src/pypsa_app/__init__.py b/src/pypsa_app/__init__.py index 0540c71d..2c160c65 100644 --- a/src/pypsa_app/__init__.py +++ b/src/pypsa_app/__init__.py @@ -1,5 +1,7 @@ """PyPSA App""" -__version__ = "0.1.0" +from importlib.metadata import version + +__version__ = version("pypsa-app") __all__ = ["__version__"] diff --git a/src/pypsa_app/backend/alembic/env.py b/src/pypsa_app/backend/alembic/env.py index 6e1b963f..76f30735 100644 --- a/src/pypsa_app/backend/alembic/env.py +++ b/src/pypsa_app/backend/alembic/env.py @@ -61,6 +61,14 @@ def run_migrations_online() -> None: cfg = config.get_section(config.config_ini_section, {}) cfg["sqlalchemy.url"] = settings.database_url + connection = config.attributes.get("connection") + if connection is not None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + return + connectable = engine_from_config( cfg, prefix="sqlalchemy.", diff --git a/src/pypsa_app/backend/api/routes/api_keys.py b/src/pypsa_app/backend/api/routes/api_keys.py index a35db433..cbe7c90c 100644 --- a/src/pypsa_app/backend/api/routes/api_keys.py +++ b/src/pypsa_app/backend/api/routes/api_keys.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session from pypsa_app.backend.api.deps import get_db, require_permission -from pypsa_app.backend.auth import hash_api_key +from pypsa_app.backend.auth.authenticate import hash_api_key from pypsa_app.backend.models import ApiKey, Permission, User, UserRole from pypsa_app.backend.schemas.api_key import ApiKeyCreate, ApiKeyResponse diff --git a/src/pypsa_app/backend/api/routes/auth.py b/src/pypsa_app/backend/api/routes/auth.py index 9ab0e0f7..989d4037 100644 --- a/src/pypsa_app/backend/api/routes/auth.py +++ b/src/pypsa_app/backend/api/routes/auth.py @@ -1,19 +1,29 @@ -"""Authentication routes for GitHub OAuth""" +"""Authentication routes""" import logging -from datetime import UTC, datetime -from urllib.parse import urlparse -from authlib.integrations.starlette_client import OAuth, OAuthError +from authlib.integrations.starlette_client import OAuthError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request -from fastapi.responses import RedirectResponse +from fastapi.responses import JSONResponse, RedirectResponse +from pydantic import BaseModel, EmailStr, Field from sqlalchemy import select from sqlalchemy.orm import Session from pypsa_app.backend.api.deps import get_current_user_optional, get_db -from pypsa_app.backend.auth.session import get_session_store -from pypsa_app.backend.models import User, UserOAuthProvider, UserRole -from pypsa_app.backend.schemas.auth import UserResponse +from pypsa_app.backend.auth.password import verify_demo_credentials +from pypsa_app.backend.auth.providers import ( + OAUTH_PROVIDERS, + get_oauth_client, + login_or_register_oauth_user, +) +from pypsa_app.backend.auth.session import attach_session_cookie, get_session_store +from pypsa_app.backend.models import User, UserRole +from pypsa_app.backend.ratelimit import limiter +from pypsa_app.backend.schemas.auth import ( + AuthProviderInfo, + AuthProvidersResponse, + UserResponse, +) from pypsa_app.backend.services.email import send_new_user_pending_email from pypsa_app.backend.settings import SESSION_COOKIE_NAME, settings @@ -32,121 +42,67 @@ def _get_admin_emails(db: Session) -> list[str]: ] -def _create_session_response(user_id: int, redirect_url: str) -> RedirectResponse: - """Create a redirect response with a session cookie.""" - session_store = get_session_store() - session_id = session_store.create_session(user_id) - response = RedirectResponse(url=redirect_url) - is_localhost = urlparse(settings.base_url).hostname in ( - "localhost", - "127.0.0.1", - "::1", - ) - response.set_cookie( - key=SESSION_COOKIE_NAME, - value=session_id, - httponly=True, - secure=not is_localhost, - samesite="lax", - max_age=settings.session_ttl, - ) - return response +class PasswordLoginRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=1, max_length=128) -oauth = OAuth() -oauth.register( - name="github", - client_id=settings.github_client_id, - client_secret=settings.github_client_secret, - access_token_url="https://github.com/login/oauth/access_token", # noqa: S106 - authorize_url="https://github.com/login/oauth/authorize", - api_base_url="https://api.github.com/", - client_kwargs={"scope": "user:email"}, -) - +@router.get("/providers", response_model=AuthProvidersResponse) +async def get_auth_providers() -> AuthProvidersResponse: + items: list[AuthProviderInfo] = [] + for pid in settings.enabled_oauth_providers: + spec = OAUTH_PROVIDERS[pid] + items.append( + AuthProviderInfo( + id=spec.id, + name=spec.display_name, + type="oauth", + login_url=f"/api/v1/auth/login/{spec.id}", + icon=spec.icon, + ) + ) + if settings.auth_password_enabled: + items.append( + AuthProviderInfo(id="password", name="Password", type="credentials") + ) + return AuthProvidersResponse(providers=items) -@router.get("/login") -async def login(request: Request) -> RedirectResponse: - """Redirect to GitHub OAuth login""" - if not settings.enable_auth: - raise HTTPException(status_code=400, detail="Authentication is disabled") - callback_url = f"{settings.base_url}/api/v1/auth/callback" - # Use authlib to auto-generates state and stores in session - return await oauth.github.authorize_redirect(request, callback_url) +@router.get("/login/{provider}") +async def oauth_login(provider: str, request: Request) -> RedirectResponse: + if provider not in settings.enabled_oauth_providers: + raise HTTPException( + status_code=404, detail=f"OAuth provider '{provider}' not enabled" + ) + client = get_oauth_client(settings, provider) + callback_url = f"{settings.base_url}/api/v1/auth/login/{provider}/callback" + return await client.authorize_redirect(request, callback_url) -@router.get("/callback") -async def callback( +@router.get("/login/{provider}/callback") +async def oauth_callback( + provider: str, request: Request, background_tasks: BackgroundTasks, db: Session = Depends(get_db), ) -> RedirectResponse: - """Handle GitHub OAuth callback""" - if not settings.enable_auth: - raise HTTPException(status_code=400, detail="Authentication is disabled") + if provider not in settings.enabled_oauth_providers: + raise HTTPException( + status_code=404, detail=f"OAuth provider '{provider}' not enabled" + ) + spec = OAUTH_PROVIDERS[provider] + client = get_oauth_client(settings, provider) try: - # Use authlib to auto-validates state and raises OAuthError if invalid - token = await oauth.github.authorize_access_token(request) + token = await client.authorize_access_token(request) except OAuthError as e: client_ip = request.client.host if request.client else "unknown" logger.warning("OAuth error (possible CSRF): %s, client_ip=%s", e, client_ip) raise HTTPException(status_code=400, detail="Authentication failed") from e try: - # Get user info from GitHub - resp = await oauth.github.get("user", token=token) - github_user = resp.json() - - # Get user email (if available) - email_resp = await oauth.github.get("user/emails", token=token) - emails = email_resp.json() - primary_email = next((e["email"] for e in emails if e["primary"]), None) - - # Get Oauth link - provider_id = str(github_user["id"]) - oauth_link = db.scalars( - select(UserOAuthProvider).where( - UserOAuthProvider.provider == "github", - UserOAuthProvider.provider_id == provider_id, - ) - ).first() - - if oauth_link: - # Existing user - just update last_login (profile stays unchanged) - user = db.get(User, oauth_link.user_id) - user.update_last_login() - logger.info("User logged in: %s (role: %s)", user.username, user.role) - else: - # New user - first user becomes admin, others are pending - existing_user = db.scalars(select(User).limit(1)).first() - is_first_user = existing_user is None - - if is_first_user: - role = UserRole.ADMIN - logger.warning("First user %s promoted to admin.", github_user["login"]) - else: - role = UserRole.PENDING - - user = User( - username=github_user["login"], - email=primary_email, - avatar_url=github_user.get("avatar_url"), - last_login=datetime.now(UTC), - role=role, - ) - db.add(user) - db.flush() - - oauth_link = UserOAuthProvider( - user_id=user.id, - provider="github", - provider_id=provider_id, - ) - db.add(oauth_link) - logger.info("New user registered: %s (role: %s)", user.username, user.role) - + canonical = await spec.fetch_user(client, token) + user = login_or_register_oauth_user(db, provider=provider, canonical=canonical) is_pending = user.role == UserRole.PENDING admin_emails = _get_admin_emails(db) if is_pending else [] @@ -160,31 +116,46 @@ async def callback( send_new_user_pending_email, admin_emails, user.username ) - response = _create_session_response(user.id, redirect_url) - + response = RedirectResponse(url=redirect_url) + return attach_session_cookie( + response, user.id, base_url=settings.base_url, ttl=settings.session_ttl + ) except Exception as e: logger.exception("OAuth callback error") raise HTTPException(status_code=500, detail="Authentication failed") from e - else: - return response + + +@router.post("/login/password") +@limiter.limit(settings.ratelimit_login) +async def password_login( + request: Request, + body: PasswordLoginRequest, + db: Session = Depends(get_db), +) -> JSONResponse: + if not settings.auth_password_enabled: + raise HTTPException(status_code=400, detail="Password login not available") + + user = verify_demo_credentials(body.email, body.password, db) + if user is None: + raise HTTPException(status_code=401, detail="Invalid credentials") + + response = JSONResponse(content={"ok": True}) + return attach_session_cookie( + response, user.id, base_url=settings.base_url, ttl=settings.session_ttl + ) @router.get("/logout") async def logout(request: Request) -> RedirectResponse: - """Logout and delete session""" - if not settings.enable_auth: + if not settings.auth_enabled: raise HTTPException(status_code=400, detail="Authentication is disabled") session_id = request.cookies.get(SESSION_COOKIE_NAME) - if session_id: - session_store = get_session_store() - session_store.delete_session(session_id) + get_session_store().delete_session(session_id) - # Clear session cookie response = RedirectResponse(url=f"{settings.base_url}/login", status_code=303) response.delete_cookie(key=SESSION_COOKIE_NAME) - return response @@ -192,8 +163,7 @@ async def logout(request: Request) -> RedirectResponse: async def get_current_user_info( user: User | None = Depends(get_current_user_optional), ) -> User: - """Get current authenticated user information""" - if not settings.enable_auth: + if not settings.auth_enabled: raise HTTPException(status_code=400, detail="Authentication is disabled") if user is None: raise HTTPException( diff --git a/src/pypsa_app/backend/api/routes/plots.py b/src/pypsa_app/backend/api/routes/plots.py index ab0edc6b..f7ad21b1 100644 --- a/src/pypsa_app/backend/api/routes/plots.py +++ b/src/pypsa_app/backend/api/routes/plots.py @@ -1,13 +1,15 @@ import logging -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request, Response from sqlalchemy.orm import Session from pypsa_app.backend.api.deps import get_db, get_networks, require_permission from pypsa_app.backend.api.utils.task_utils import queue_task from pypsa_app.backend.models import Permission, User -from pypsa_app.backend.schemas.plot import ExploreRequest, PlotRequest +from pypsa_app.backend.ratelimit import limiter +from pypsa_app.backend.schemas.plot import ExploreRequest, PlotParams from pypsa_app.backend.schemas.task import TaskQueuedResponse +from pypsa_app.backend.settings import settings from pypsa_app.backend.tasks import get_explore_task, get_plot_task router = APIRouter() @@ -15,38 +17,44 @@ @router.post("/generate", response_model=TaskQueuedResponse) +@limiter.limit(settings.ratelimit_expensive) def generate_plot( - request: PlotRequest, + request: Request, + response: Response, + body: PlotParams, db: Session = Depends(get_db), user: User = Depends(require_permission(Permission.NETWORKS_VIEW)), ) -> dict: """Generate plot from PyPSA network or NetworkCollection statistics""" - networks = get_networks(db, request.network_ids, user) + networks = get_networks(db, body.network_ids, user) file_paths = [net.file_path for net in networks] return queue_task( get_plot_task, file_paths=file_paths, - statistic=request.statistic, - plot_type=request.plot_type, - parameters=request.parameters, + statistic=body.statistic, + plot_type=body.plot_type, + parameters=body.parameters, ) @router.post("/explore", response_model=TaskQueuedResponse) +@limiter.limit(settings.ratelimit_expensive) def generate_explore( - request: ExploreRequest, + request: Request, + response: Response, + body: ExploreRequest, db: Session = Depends(get_db), user: User = Depends(require_permission(Permission.NETWORKS_VIEW)), ) -> dict: """Generate interactive map from PyPSA network using n.plot.explore()""" - networks = get_networks(db, [request.network_id], user) + networks = get_networks(db, [body.network_id], user) file_paths = [net.file_path for net in networks] parameters: dict = {} - if request.branch_components is not None: - parameters["branch_components"] = request.branch_components - if request.geometry: + if body.branch_components is not None: + parameters["branch_components"] = body.branch_components + if body.geometry: parameters["geometry"] = True return queue_task( diff --git a/src/pypsa_app/backend/api/routes/runs.py b/src/pypsa_app/backend/api/routes/runs.py index 110647ea..62811c0c 100644 --- a/src/pypsa_app/backend/api/routes/runs.py +++ b/src/pypsa_app/backend/api/routes/runs.py @@ -145,9 +145,13 @@ def create_run( payload["cache_key"] = body.cache.key payload["cache_dirs"] = body.cache.dirs result = client.submit_job(payload) + try: + job_id = uuid.UUID(str(result["job_id"])) + except (KeyError, ValueError, TypeError) as exc: + raise HTTPException(502, "Snakedispatch returned an invalid job_id") from exc run = Run( - job_id=result["job_id"], + job_id=job_id, user_id=user.id, backend_id=backend.id, workflow=result.get("workflow", body.workflow), @@ -167,7 +171,7 @@ def create_run( logger.info( "Run created", extra={ - "run_id": result["job_id"], + "run_id": str(job_id), "user": user.username, "backend": backend.name, }, @@ -325,6 +329,24 @@ def stream_run_logs( ) -> StreamingResponse: """Stream live logs via SSE, or plain text with ?format=text.""" run = auth.model + if settings.demo_mode: + if run.status in {RunStatus.PENDING, RunStatus.SETUP}: + body = "Waiting for logs...\n" + else: + log_path = settings.data_dir_path / "pypsa_demo_run_logs.txt" + body = ( + log_path.read_text() if log_path.exists() else "(no demo logs file)\n" + ) + if format == "text": + return StreamingResponse(iter([body]), media_type="text/plain") + sse_payload = ( + "".join(f"data: {line}\n\n" for line in body.splitlines()) + + "event: done\ndata: \n\n" + ) + return StreamingResponse( + iter([sse_payload]), + media_type="text/event-stream", + ) sd_client = _get_client_for_run(run) if format == "text": return StreamingResponse( @@ -337,6 +359,16 @@ def stream_run_logs( ) +@router.post("/{run_id}/logs/reveal", response_model=MessageResponse) +def reveal_run_logs( + auth: Authorized[Run] = Depends(require_run("read")), +) -> dict: + """Reveal the persisted run log file on the local machine, if available.""" + run = auth.model + sd_client = _get_client_for_run(run) + return sd_client.reveal_job_log(str(run.job_id)) + + @cache("run_outputs", ttl=settings.run_outputs_cache_ttl) def _get_job_outputs_cached(job_id: str, backend_id: str) -> list[dict]: """Fetch job outputs via Snakedispatch (cached at module level).""" @@ -352,6 +384,13 @@ def get_run_workflow( ) -> dict: """Get workflow metadata (DAG, rules, jobs, errors) for a run.""" run = auth.model + if settings.demo_mode: + import json as _json # noqa: PLC0415 + + fixture = settings.data_dir_path / "demo_workflows" / f"{run.job_id}.json" + if fixture.exists(): + return _json.loads(fixture.read_text()) + return {"rules": [], "rulegraph": {"nodes": []}, "errors": []} client = _get_client_for_run(run) return client.get_job_workflow(str(run.job_id)) diff --git a/src/pypsa_app/backend/api/routes/statistics.py b/src/pypsa_app/backend/api/routes/statistics.py index 4740324f..6e13d185 100644 --- a/src/pypsa_app/backend/api/routes/statistics.py +++ b/src/pypsa_app/backend/api/routes/statistics.py @@ -1,13 +1,15 @@ import logging -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request, Response from sqlalchemy.orm import Session from pypsa_app.backend.api.deps import get_db, get_networks, require_permission from pypsa_app.backend.api.utils.task_utils import queue_task from pypsa_app.backend.models import Permission, User -from pypsa_app.backend.schemas.statistics import StatisticsRequest +from pypsa_app.backend.ratelimit import limiter +from pypsa_app.backend.schemas.statistics import StatisticsParams from pypsa_app.backend.schemas.task import TaskQueuedResponse +from pypsa_app.backend.settings import settings from pypsa_app.backend.tasks import get_statistics_task router = APIRouter() @@ -15,18 +17,21 @@ @router.post("/", response_model=TaskQueuedResponse) +@limiter.limit(settings.ratelimit_expensive) def get_statistics( - request: StatisticsRequest, + request: Request, + response: Response, + body: StatisticsParams, db: Session = Depends(get_db), user: User = Depends(require_permission(Permission.NETWORKS_VIEW)), ) -> dict: """Get raw statistics data without plotting""" - networks = get_networks(db, request.network_ids, user) + networks = get_networks(db, body.network_ids, user) file_paths = [net.file_path for net in networks] return queue_task( get_statistics_task, file_paths=file_paths, - statistic=request.statistic, - parameters=request.parameters, + statistic=body.statistic, + parameters=body.parameters, ) diff --git a/src/pypsa_app/backend/api/routes/version.py b/src/pypsa_app/backend/api/routes/version.py index b10a8425..2dbb6ced 100644 --- a/src/pypsa_app/backend/api/routes/version.py +++ b/src/pypsa_app/backend/api/routes/version.py @@ -17,5 +17,6 @@ async def get_version() -> dict: "version": __version__, "pypsa_version": pypsa.__version__, "local_mode": settings.local_mode, - "runs_enabled": bool(settings.resolved_backends), + "demo_mode": settings.demo_mode, + "runs_enabled": bool(settings.resolved_backends) or settings.demo_mode, } diff --git a/src/pypsa_app/backend/auth/__init__.py b/src/pypsa_app/backend/auth/__init__.py index 2837a80e..35ab598f 100644 --- a/src/pypsa_app/backend/auth/__init__.py +++ b/src/pypsa_app/backend/auth/__init__.py @@ -1,13 +1 @@ """Authentication module""" - -from pypsa_app.backend.auth.authenticate import ( - hash_api_key, - resolve_current_user, - set_auth_disabled_user, -) - -__all__ = [ - "hash_api_key", - "resolve_current_user", - "set_auth_disabled_user", -] diff --git a/src/pypsa_app/backend/auth/authenticate.py b/src/pypsa_app/backend/auth/authenticate.py index db4ae8b6..92af74b1 100644 --- a/src/pypsa_app/backend/auth/authenticate.py +++ b/src/pypsa_app/backend/auth/authenticate.py @@ -19,7 +19,7 @@ def set_auth_disabled_user(user: User) -> None: """Store a pre created system user for auth disabled mode.""" - if settings.enable_auth: + if settings.auth_enabled: msg = "Cannot set auth-disabled user when authentication is enabled" raise RuntimeError(msg) global _auth_disabled_user # noqa: PLW0603 @@ -32,7 +32,7 @@ def ensure_system_user(db: Session) -> User: Only callable when authentication is disabled. The returned user is also registered as the implicit caller for auth-disabled requests. """ - if settings.enable_auth: + if settings.auth_enabled: msg = "Cannot create system user when authentication is enabled" raise RuntimeError(msg) system_user = db.scalars(select(User).where(User.username == "system")).first() @@ -45,6 +45,17 @@ def ensure_system_user(db: Session) -> User: return system_user +def ensure_demo_user(db: Session) -> User: + """Return the shared demo user, creating it if missing.""" + demo_user = db.scalars(select(User).where(User.username == "demo")).first() + if not demo_user: + demo_user = User(username="demo", role=UserRole.ADMIN) + db.add(demo_user) + db.commit() + db.refresh(demo_user) + return demo_user + + def hash_api_key(token: str) -> str: """SHA-256 hash of a raw API key token.""" return hashlib.sha256(token.encode()).hexdigest() @@ -79,6 +90,11 @@ def _authenticate_api_key(token: str, db: Session) -> User | None: def resolve_current_user(request: Request, db: Session) -> User | None: """Return authenticated user or None, never blocking requests.""" + # Reuse the user already resolved by the rate limit middleware + cached = getattr(request.state, "user", None) if hasattr(request, "state") else None + if cached is not None: + return cached + if _auth_disabled_user is not None: return _auth_disabled_user diff --git a/src/pypsa_app/backend/auth/password.py b/src/pypsa_app/backend/auth/password.py new file mode 100644 index 00000000..74bf4a3b --- /dev/null +++ b/src/pypsa_app/backend/auth/password.py @@ -0,0 +1,22 @@ +"""Demo-mode credential verifier. + +Password auth is currently only enabled in DEMO_MODE (enforced in settings).""" + +from secrets import compare_digest + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from pypsa_app.backend.models import User + +DEMO_EMAIL = "demo@example.org" +DEMO_PASSWORD = "demopypsa" # noqa: S105 + + +def verify_demo_credentials(email: str, password: str, db: Session) -> User | None: + """Return the shared demo user if credentials match the seeded values.""" + email_ok = compare_digest(email.encode(), DEMO_EMAIL.encode()) + password_ok = compare_digest(password.encode(), DEMO_PASSWORD.encode()) + if not (email_ok and password_ok): + return None + return db.scalars(select(User).where(User.username == "demo")).first() diff --git a/src/pypsa_app/backend/auth/providers.py b/src/pypsa_app/backend/auth/providers.py new file mode 100644 index 00000000..fac7e8ba --- /dev/null +++ b/src/pypsa_app/backend/auth/providers.py @@ -0,0 +1,170 @@ +"""Declarative OAuth provider registry and helpers. + +Adding a new provider takes one entry in OAUTH_PROVIDERS plus two settings +fields (AUTH__CLIENT_ID / AUTH__CLIENT_SECRET). +""" + +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from authlib.integrations.starlette_client import OAuth +from sqlalchemy import select + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + + from pypsa_app.backend.models import User + from pypsa_app.backend.settings import Settings + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CanonicalOAuthUser: + """Provider-agnostic user identity returned by every fetch_user impl.""" + + provider_id: str + username: str + email: str | None + avatar_url: str | None + + +@dataclass(frozen=True) +class OAuthProviderSpec: + id: str + display_name: str + icon: str | None + authorize_url: str + access_token_url: str + api_base_url: str + scope: str + fetch_user: Callable[[Any, dict[str, Any]], Awaitable[CanonicalOAuthUser]] + + +async def _fetch_github_user(client: Any, token: dict[str, Any]) -> CanonicalOAuthUser: + # Primary email needs a separate /user/emails call. + user_resp = await client.get("user", token=token) + info = user_resp.json() + emails_resp = await client.get("user/emails", token=token) + primary = next((e["email"] for e in emails_resp.json() if e["primary"]), None) + return CanonicalOAuthUser( + provider_id=str(info["id"]), + username=info["login"], + email=primary, + avatar_url=info.get("avatar_url"), + ) + + +OAUTH_PROVIDERS: dict[str, OAuthProviderSpec] = { + "github": OAuthProviderSpec( + id="github", + display_name="GitHub", + icon="github", + authorize_url="https://github.com/login/oauth/authorize", + access_token_url="https://github.com/login/oauth/access_token", # noqa: S106 + api_base_url="https://api.github.com/", + scope="user:email", + fetch_user=_fetch_github_user, + ), +} + + +_oauth = OAuth() +_clients: dict[str, Any] = {} + + +def _register_client(settings: "Settings", pid: str) -> Any: + spec = OAUTH_PROVIDERS[pid] + _oauth.register( + name=pid, + client_id=getattr(settings, f"auth_{pid}_client_id"), + client_secret=getattr(settings, f"auth_{pid}_client_secret"), + authorize_url=spec.authorize_url, + access_token_url=spec.access_token_url, + api_base_url=spec.api_base_url, + client_kwargs={"scope": spec.scope}, + ) + client = getattr(_oauth, pid) + _clients[pid] = client + return client + + +def build_oauth_clients(settings: "Settings") -> None: + """Register a client for each enabled provider. Safe to call repeatedly.""" + for pid in settings.enabled_oauth_providers: + if pid not in _clients: + _register_client(settings, pid) + + +def get_oauth_client(settings: "Settings", provider: str) -> Any: + """Return authlib client, registering it on first access.""" + return _clients.get(provider) or _register_client(settings, provider) + + +def login_or_register_oauth_user( + db: "Session", *, provider: str, canonical: CanonicalOAuthUser +) -> "User": + """Find or create a user linked to the given OAuth identity. + + First user ever to log in is promoted to admin. Later new users land in + PENDING until an admin approves them. + """ + from pypsa_app.backend.models import ( # noqa: PLC0415 + User, + UserOAuthProvider, + UserRole, + ) + + oauth_link = db.scalars( + select(UserOAuthProvider).where( + UserOAuthProvider.provider == provider, + UserOAuthProvider.provider_id == canonical.provider_id, + ) + ).first() + + user: User | None = None + if oauth_link: + user = db.get(User, oauth_link.user_id) + if user is None: + logger.warning( + "Orphan oauth_link for %s/%s -> user_id=%s;" + " deleting and re-registering", + provider, + canonical.provider_id, + oauth_link.user_id, + ) + db.delete(oauth_link) + db.flush() + oauth_link = None + + if user is not None: + user.update_last_login() + logger.info("User logged in: %s (role: %s)", user.username, user.role) + else: + if db.scalars(select(User).limit(1)).first() is None: + role = UserRole.ADMIN + logger.warning("First user %s promoted to admin.", canonical.username) + else: + role = UserRole.PENDING + + user = User( + username=canonical.username, + email=canonical.email, + avatar_url=canonical.avatar_url, + role=role, + ) + user.update_last_login() + db.add(user) + db.flush() + + oauth_link = UserOAuthProvider( + user_id=user.id, + provider=provider, + provider_id=canonical.provider_id, + ) + db.add(oauth_link) + logger.info("New user registered: %s (role: %s)", user.username, user.role) + + return user diff --git a/src/pypsa_app/backend/auth/session.py b/src/pypsa_app/backend/auth/session.py index 65dd75cd..0d2e53a5 100644 --- a/src/pypsa_app/backend/auth/session.py +++ b/src/pypsa_app/backend/auth/session.py @@ -2,8 +2,11 @@ import logging import secrets +from urllib.parse import urlparse from uuid import UUID +from starlette.responses import Response + try: import redis @@ -11,7 +14,7 @@ except ImportError: REDIS_AVAILABLE = False -from pypsa_app.backend.settings import settings +from pypsa_app.backend.settings import SESSION_COOKIE_NAME, settings logger = logging.getLogger(__name__) @@ -143,3 +146,21 @@ def get_session_store() -> SessionStore: msg = "Session store not initialized. Enable authentication in settings." raise RuntimeError(msg) return session_store + + +def attach_session_cookie( + response: Response, user_id: UUID, *, base_url: str, ttl: int +) -> Response: + """Set the session cookie on any Response (JSON or Redirect).""" + store = get_session_store() + session_id = store.create_session(user_id) + is_localhost = urlparse(base_url).hostname in ("localhost", "127.0.0.1", "::1") + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=session_id, + httponly=True, + secure=not is_localhost, + samesite="lax", + max_age=ttl, + ) + return response diff --git a/src/pypsa_app/backend/main.py b/src/pypsa_app/backend/main.py index b4ac4239..d684521f 100644 --- a/src/pypsa_app/backend/main.py +++ b/src/pypsa_app/backend/main.py @@ -8,8 +8,10 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from slowapi.errors import RateLimitExceeded from sqlalchemy import select, text from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import Response from pypsa_app.backend.__version__ import __description__, __version__ from pypsa_app.backend.alembic import run_migrations @@ -29,14 +31,24 @@ version, ) from pypsa_app.backend.auth import session -from pypsa_app.backend.auth.authenticate import ensure_system_user +from pypsa_app.backend.auth.authenticate import ( + ensure_demo_user, + ensure_system_user, + resolve_current_user, +) +from pypsa_app.backend.auth.providers import build_oauth_clients from pypsa_app.backend.cache import cache_service from pypsa_app.backend.database import SessionLocal, engine from pypsa_app.backend.models import SnakedispatchBackend +from pypsa_app.backend.ratelimit import ( + APIRateLimitMiddleware, + limiter, + rate_limit_exceeded_handler, +) from pypsa_app.backend.services.backend_registry import backend_registry from pypsa_app.backend.services.run import SnakedispatchError from pypsa_app.backend.services.sync import run_sync_loop -from pypsa_app.backend.settings import API_V1_PREFIX, settings +from pypsa_app.backend.settings import API_V1_PREFIX, SESSION_COOKIE_NAME, settings logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" @@ -114,37 +126,30 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: try: db.execute(text("SELECT 1")) logger.info("Database ready") - if not settings.enable_auth: + if settings.demo_mode: + ensure_demo_user(db) + elif not settings.auth_enabled: ensure_system_user(db) finally: db.close() - # Initialize authentication if enabled - if settings.enable_auth: - logger.info( - "Authentication enabled - initializing session store", - extra={ - "github_client_id": settings.github_client_id, - "session_ttl": settings.session_ttl, - }, - ) - - # Verify required auth settings - if not settings.github_client_id or not settings.github_client_secret: - msg = ( - "Authentication is enabled but GitHub OAuth" - " credentials are not configured. " - "Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET" - " environment variables." + if settings.auth_enabled: + if settings.auth_oauth_enabled: + logger.info( + "OAuth providers enabled: %s", + settings.enabled_oauth_providers, + extra={"session_ttl": settings.session_ttl}, ) - raise RuntimeError(msg) + build_oauth_clients(settings) + + if settings.auth_password_enabled: + logger.info("Password authentication enabled") # Verify Redis is available (required for session storage) if not cache_service.ping(): msg = ( - "Authentication is enabled but Redis is not available. " - "Redis is required for session storage when authentication is enabled. " - "Set REDIS_URL environment variable and ensure Redis is running." + "Session based auth requires Redis. " + "Set REDIS_URL and ensure Redis is running." ) raise RuntimeError(msg) @@ -152,9 +157,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: session.session_store = session.SessionStore() logger.info( "Session store initialized", - extra={ - "redis_url": settings.redis_url, - }, + extra={"redis_url": settings.redis_url}, ) else: logger.info("Authentication disabled") @@ -210,6 +213,64 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: https_only=not settings.base_url.startswith("http://localhost"), ) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) +app.add_middleware(APIRateLimitMiddleware) + + +@app.middleware("http") +async def _attach_user_for_ratelimit( + request: Request, + call_next, # noqa: ANN001 +) -> Response: + """Resolve identity once per request so the rate-limit key_func can see it.""" + request.state.user = None + if not settings.auth_enabled or request.method == "OPTIONS": + return await call_next(request) + has_session = SESSION_COOKIE_NAME in request.cookies + auth_header = request.headers.get("authorization", "") + has_bearer = auth_header.lower().startswith("bearer ") + if not has_session and not has_bearer: + return await call_next(request) + try: + db = SessionLocal() + try: + request.state.user = resolve_current_user(request, db) + finally: + db.close() + except Exception: # noqa: BLE001 + # Route's auth dependency handles the status code + logger.warning("ratelimit user resolution failed", exc_info=True) + return await call_next(request) + + +if settings.demo_mode: + _DEMO_POST_ALLOWLIST = frozenset( + { + f"{API_V1_PREFIX}/auth/login/password", + f"{API_V1_PREFIX}/plots/generate", + f"{API_V1_PREFIX}/plots/explore", + f"{API_V1_PREFIX}/statistics/", + f"{API_V1_PREFIX}/statistics", + } + ) + + @app.middleware("http") + async def _demo_readonly( + request: Request, + call_next, # noqa: ANN001 + ) -> JSONResponse: + if ( + request.method not in ("GET", "HEAD", "OPTIONS") + and request.url.path not in _DEMO_POST_ALLOWLIST + ): + return JSONResponse( + status_code=403, + content={"detail": "Demo deployment is read-only"}, + ) + return await call_next(request) + + # Configure CORS (only needed in dev mode with separate frontend server) if settings.backend_only: # Parse comma-separated CORS origins from environment variable @@ -256,8 +317,8 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp # Include routers -if settings.enable_auth: - app.include_router(auth.router, prefix=f"{API_V1_PREFIX}/auth", tags=["auth"]) +app.include_router(auth.router, prefix=f"{API_V1_PREFIX}/auth", tags=["auth"]) +if settings.auth_enabled: app.include_router( api_keys.router, prefix=f"{API_V1_PREFIX}/auth/api-keys", tags=["auth"] ) @@ -266,14 +327,17 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp app.include_router( networks.router, prefix=f"{API_V1_PREFIX}/networks", tags=["networks"] ) -if settings.local_mode: - app.include_router( - networks_local.router, prefix=f"{API_V1_PREFIX}/networks", tags=["networks"] - ) -else: - app.include_router( - networks_remote.router, prefix=f"{API_V1_PREFIX}/networks", tags=["networks"] - ) +if not settings.demo_mode: + if settings.local_mode: + app.include_router( + networks_local.router, prefix=f"{API_V1_PREFIX}/networks", tags=["networks"] + ) + else: + app.include_router( + networks_remote.router, + prefix=f"{API_V1_PREFIX}/networks", + tags=["networks"], + ) app.include_router(plots.router, prefix=f"{API_V1_PREFIX}/plots", tags=["plots"]) app.include_router( statistics.router, @@ -283,7 +347,7 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp app.include_router(cache.router, prefix=f"{API_V1_PREFIX}/cache", tags=["cache"]) app.include_router(version.router, prefix=f"{API_V1_PREFIX}/version", tags=["version"]) app.include_router(tasks.router, prefix=f"{API_V1_PREFIX}/tasks", tags=["tasks"]) -if settings.resolved_backends: +if settings.resolved_backends or settings.demo_mode: app.include_router(runs.router, prefix=f"{API_V1_PREFIX}/runs", tags=["runs"]) diff --git a/src/pypsa_app/backend/ratelimit.py b/src/pypsa_app/backend/ratelimit.py new file mode 100644 index 00000000..c6b65c22 --- /dev/null +++ b/src/pypsa_app/backend/ratelimit.py @@ -0,0 +1,90 @@ +"""Per route rate limiting via slowapi. + +Keyed by `user:` for authenticated non demo users, otherwise by client IP. +""" + +import logging + +from fastapi import Request +from fastapi.responses import JSONResponse +from slowapi import Limiter +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware +from starlette.middleware.base import RequestResponseEndpoint +from starlette.responses import Response + +from pypsa_app.backend.settings import settings + +logger = logging.getLogger(__name__) + + +def get_client_ip(request: Request) -> str: + if settings.trust_cloudflare_ip: + cf_ip = request.headers.get("cf-connecting-ip") + if cf_ip: + return cf_ip + if request.client: + return request.client.host + return "unknown" + + +def rate_limit_key(request: Request) -> str: + user = getattr(request.state, "user", None) if hasattr(request, "state") else None + if user is not None and getattr(user, "username", None) != "demo": + return f"user:{user.id}" + return f"ip:{get_client_ip(request)}" + + +limiter = Limiter( + key_func=rate_limit_key, + storage_uri=settings.redis_url, + enabled=bool(settings.ratelimit_enabled), + swallow_errors=True, + headers_enabled=True, + strategy="moving-window", + default_limits=[settings.ratelimit_default], +) + + +class APIRateLimitMiddleware(SlowAPIMiddleware): + """Apply slowapi's middleware-level rate limit only to /api/* paths.""" + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + if not request.url.path.startswith("/api/"): + return await call_next(request) + return await super().dispatch(request, call_next) + + +def rate_limit_exceeded_handler( + request: Request, exc: RateLimitExceeded +) -> JSONResponse: + """Return 429 with Retry-After + X-RateLimit-* headers, log the hit.""" + key = rate_limit_key(request) + logger.warning( + "Rate limit exceeded", + extra={ + "path": request.url.path, + "method": request.method, + "key": key, + "limit": str(exc.detail), + }, + ) + limit_info = getattr(request.state, "view_rate_limit", None) + headers: dict[str, str] = {} + # slowapi only exposes its rate-limit headers by mutating a Response + if limit_info is not None: + probe = Response() + request.app.state.limiter._inject_headers(probe, limit_info) + headers = { + k: v for k, v in probe.headers.items() if k.lower() != "content-length" + } + + retry_after = headers.get("retry-after") + msg = ( + f"Too many requests. Try again in {retry_after} seconds." + if retry_after + else "Too many requests. Please slow down." + ) + return JSONResponse(status_code=429, content={"detail": msg}, headers=headers) diff --git a/src/pypsa_app/backend/schemas/auth.py b/src/pypsa_app/backend/schemas/auth.py index b93f299f..8165e844 100644 --- a/src/pypsa_app/backend/schemas/auth.py +++ b/src/pypsa_app/backend/schemas/auth.py @@ -48,3 +48,15 @@ class UserCreate(BaseModel): username: str = Field(..., min_length=1, max_length=255) role: UserRole avatar_url: str | None = Field(None, max_length=512) + + +class AuthProviderInfo(BaseModel): + id: str + name: str + type: str + login_url: str | None = None + icon: str | None = None + + +class AuthProvidersResponse(BaseModel): + providers: list[AuthProviderInfo] diff --git a/src/pypsa_app/backend/schemas/plot.py b/src/pypsa_app/backend/schemas/plot.py index 6e0d5287..7286ee20 100644 --- a/src/pypsa_app/backend/schemas/plot.py +++ b/src/pypsa_app/backend/schemas/plot.py @@ -1,11 +1,11 @@ from pydantic import BaseModel, Field, field_validator -from pypsa_app.backend.schemas.statistics import StatisticsRequest +from pypsa_app.backend.schemas.statistics import StatisticsParams from pypsa_app.backend.utils.allowlists import ALLOWED_CHART_TYPES -class PlotRequest(StatisticsRequest): - """Request schema for plot generation (extends StatisticsRequest with plot_type)""" +class PlotParams(StatisticsParams): + """Request schema for plot generation (extends StatisticsParams with plot_type)""" plot_type: str = Field(..., description="Plot method (e.g., 'bar', 'area', 'line')") diff --git a/src/pypsa_app/backend/schemas/statistics.py b/src/pypsa_app/backend/schemas/statistics.py index 55e521ef..043f8d15 100644 --- a/src/pypsa_app/backend/schemas/statistics.py +++ b/src/pypsa_app/backend/schemas/statistics.py @@ -5,7 +5,7 @@ from pypsa_app.backend.utils.allowlists import ALLOWED_STATISTICS -class StatisticsRequest(BaseModel): +class StatisticsParams(BaseModel): """Request for statistics data For single network: Maps to Network.statistics.() diff --git a/src/pypsa_app/backend/schemas/version.py b/src/pypsa_app/backend/schemas/version.py index b0f00a31..3843197e 100644 --- a/src/pypsa_app/backend/schemas/version.py +++ b/src/pypsa_app/backend/schemas/version.py @@ -7,4 +7,5 @@ class VersionResponse(BaseModel): version: str pypsa_version: str local_mode: bool + demo_mode: bool runs_enabled: bool diff --git a/src/pypsa_app/backend/services/network.py b/src/pypsa_app/backend/services/network.py index 587de3db..e0e79e27 100644 --- a/src/pypsa_app/backend/services/network.py +++ b/src/pypsa_app/backend/services/network.py @@ -121,6 +121,15 @@ def stats(self) -> dict: _network_cache = NetworkCache(ttl_seconds=settings.network_cache_ttl) +def _validate_nonempty_netcdf(file_path: Path) -> None: + """Reject empty .nc files with a clear error before PyPSA/xarray loads them.""" + if file_path.suffix.lower() != ".nc": + return + if file_path.stat().st_size == 0: + msg = f"Network file is empty: {file_path}" + raise ValueError(msg) + + class NetworkService: """Service for PyPSA network operations (handles single networks)""" @@ -130,6 +139,7 @@ def __init__( """Initialize service with a network object or file path""" if isinstance(network, (Path, str)): self.file_path = validate_path(Path(network), must_exist=True) + _validate_nonempty_netcdf(self.file_path) # Try to get from cache first if use_cache and (cached_network := _network_cache.get(self.file_path)): @@ -263,6 +273,7 @@ def __init__( networks = {} for file_path, name in zip(file_paths, names, strict=True): validated = validate_path(file_path, must_exist=True) + _validate_nonempty_netcdf(validated) # Try cache first if use_cache and (cached := _network_cache.get(validated)): @@ -344,6 +355,8 @@ def import_network_file( # noqa: PLR0913 msg = "User does not have permission to import networks" raise PermissionError(msg) + _validate_nonempty_netcdf(file_path) + file_hash = _calculate_file_hash(file_path) existing = db.scalars( @@ -400,6 +413,7 @@ def register_network_in_place( if not file_path.is_file() or file_path.suffix.lower() != ".nc": msg = f"Expected an existing .nc file, got: {file_path}" raise ValueError(msg) + _validate_nonempty_netcdf(file_path) return import_network_file(file_path, file_path.name, user_id, db, is_external=True) diff --git a/src/pypsa_app/backend/services/run.py b/src/pypsa_app/backend/services/run.py index 310807d7..ba4ae2e7 100644 --- a/src/pypsa_app/backend/services/run.py +++ b/src/pypsa_app/backend/services/run.py @@ -135,6 +135,10 @@ def get_job_logs_text(self, job_id: str) -> Iterator[bytes]: elif line.startswith("event:") or line.strip() == "": continue + def reveal_job_log(self, job_id: str) -> dict: + """Ask Snakedispatch to reveal the persisted job log on disk.""" + return self._request("POST", f"/jobs/{job_id}/logs/reveal") + def download_job_output(self, job_id: str, path: str) -> Iterator[bytes]: """Download an output file without buffering into memory.""" return self._proxy_stream(f"/jobs/{job_id}/outputs/{path}") diff --git a/src/pypsa_app/backend/services/sync.py b/src/pypsa_app/backend/services/sync.py index e373631f..7d031d85 100644 --- a/src/pypsa_app/backend/services/sync.py +++ b/src/pypsa_app/backend/services/sync.py @@ -4,6 +4,7 @@ import asyncio import logging +from datetime import UTC, datetime from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -51,6 +52,23 @@ _CALLBACK_STATUSES = SYNCED_STATUSES - {RunStatus.UPLOADING} +def _normalize_job_field(field: str, value: object) -> object: + """Coerce Snakedispatch payload values into DB-friendly Python values.""" + if value is None: + return None + if field not in {"started_at", "completed_at"}: + return value + if isinstance(value, datetime): + dt = value + elif isinstance(value, str): + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + else: + return value + if dt.tzinfo is not None: + dt = dt.astimezone(UTC).replace(tzinfo=None) + return dt + + def sync_run_from_job(run: Run, job: dict, db: Session) -> bool: """Update a Run record from a Snakedispatch response dict. @@ -60,7 +78,7 @@ def sync_run_from_job(run: Run, job: dict, db: Session) -> bool: old_status = run.status changed = False for field in _SYNC_FIELDS: - new_val = job.get(field) + new_val = _normalize_job_field(field, job.get(field)) if new_val is not None and getattr(run, field) != new_val: setattr(run, field, new_val) changed = True @@ -116,15 +134,16 @@ def sync_non_terminal_runs() -> list[dict]: continue callback_runs: list[Run] = [] for run in backend_runs: + run_id = str(run.job_id) try: - job = client.get_job(str(run.job_id)) + job = client.get_job(run_id) if sync_run_from_job(run, job, db): callback_runs.append(run) except SnakedispatchError as exc: if exc.status_code == 404: # noqa: PLR2004 logger.warning( "Run %s not found on backend %s, marking as ERROR", - run.job_id, + run_id, backend_id, ) run.status = RunStatus.ERROR @@ -132,14 +151,15 @@ def sync_non_terminal_runs() -> list[dict]: else: logger.warning( "Transient error syncing run %s on backend %s: %s", - run.job_id, + run_id, backend_id, exc.detail, ) except Exception: + db.rollback() logger.warning( "Unexpected error syncing run %s on backend %s", - run.job_id, + run_id, backend_id, exc_info=True, ) diff --git a/src/pypsa_app/backend/settings.py b/src/pypsa_app/backend/settings.py index bb1c0d49..950844d3 100644 --- a/src/pypsa_app/backend/settings.py +++ b/src/pypsa_app/backend/settings.py @@ -1,5 +1,6 @@ """Application configuration using environment variables""" +import logging from pathlib import Path from typing import Self @@ -7,6 +8,8 @@ from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +logger = logging.getLogger(__name__) + API_V1_PREFIX = "/api/v1" SESSION_COOKIE_NAME = "pypsa_session" @@ -39,7 +42,15 @@ class Settings(BaseSettings): description=( "Single-user local-dashboard deployment (the bare `pypsa-app` CLI). " "Enables zero-copy in-place network registration. " - "Incompatible with ENABLE_AUTH=true." + "Incompatible with any authentication." + ), + json_schema_extra={"category": "Application"}, + ) + demo_mode: bool = Field( + default=False, + description=( + "Public read-only demo deployment. Disables all write endpoints, " + "uses a shared 'demo' user." ), json_schema_extra={"category": "Application"}, ) @@ -72,34 +83,53 @@ def networks_path(self) -> Path: ) # Authentication - enable_auth: bool = Field( - default=False, - description="Enable GitHub OAuth authentication", - json_schema_extra={"category": "Authentication"}, - ) - github_client_id: str | None = Field( + auth_github_client_id: str | None = Field( default=None, description="GitHub OAuth app client ID (create at https://github.com/settings/developers)", - json_schema_extra={"category": "Authentication", "depends_on": "enable_auth"}, + json_schema_extra={"category": "Authentication"}, ) - github_client_secret: str | None = Field( + auth_github_client_secret: str | None = Field( default=None, description="GitHub OAuth app client secret", - json_schema_extra={"category": "Authentication", "depends_on": "enable_auth"}, + json_schema_extra={"category": "Authentication"}, + ) + auth_password_enabled: bool = Field( + default=False, + description="Enable password based login", + json_schema_extra={"category": "Authentication"}, ) session_secret_key: str = Field( default="dev-secret-key-change-in-production", description=( "Secret key for session cookies (generate with: openssl rand -base64 32)" ), - json_schema_extra={"category": "Authentication", "depends_on": "enable_auth"}, + json_schema_extra={"category": "Authentication"}, ) session_ttl: int = Field( default=604800, description="Session time-to-live in seconds (default: 7 days)", - json_schema_extra={"category": "Authentication", "depends_on": "enable_auth"}, + json_schema_extra={"category": "Authentication"}, ) + @property + def enabled_oauth_providers(self) -> list[str]: + from pypsa_app.backend.auth.providers import OAUTH_PROVIDERS # noqa: PLC0415 + + return [ + p + for p in OAUTH_PROVIDERS + if getattr(self, f"auth_{p}_client_id", None) is not None + ] + + @property + def auth_oauth_enabled(self) -> bool: + return bool(self.enabled_oauth_providers) + + @property + def auth_enabled(self) -> bool: + """True if any auth provider is active.""" + return self.auth_oauth_enabled or self.auth_password_enabled + # Networks max_upload_size_mb: int = Field( default=2000, @@ -189,6 +219,36 @@ def resolved_backends(self) -> list[dict[str, str]]: json_schema_extra={"category": "Redis", "depends_on": "redis_url"}, ) + # Rate limiting + ratelimit_enabled: bool | None = Field( + default=None, + description=("Enable per route rate limiting. Auto on when LOCAL_MODE is off."), + json_schema_extra={"category": "Rate limiting"}, + ) + ratelimit_default: str = Field( + default="120/minute", + description="Default per key rate limit applied to all routes", + json_schema_extra={"category": "Rate limiting"}, + ) + ratelimit_login: str = Field( + default="5/minute;20/hour", + description="Rate limit for POST /auth/login/password", + json_schema_extra={"category": "Rate limiting"}, + ) + ratelimit_expensive: str = Field( + default="60/minute;600/hour", + description="Rate limit for task queueing routes (plots, statistics).", + json_schema_extra={"category": "Rate limiting"}, + ) + trust_cloudflare_ip: bool = Field( + default=False, + description=( + "Trust the CF-Connecting-IP header as the client IP for rate limiting. " + "Only enable when the app sits behind a Cloudflare tunnel." + ), + json_schema_extra={"category": "Rate limiting"}, + ) + # SMTP smtp_host: str | None = Field( default=None, @@ -241,37 +301,85 @@ def smtp_enabled(self) -> bool: json_schema_extra={"category": "Development", "depends_on": "backend_only"}, ) + @model_validator(mode="after") + def validate_oauth_credential_pairs(self) -> Self: + from pypsa_app.backend.auth.providers import OAUTH_PROVIDERS # noqa: PLC0415 + + for pid in OAUTH_PROVIDERS: + cid = getattr(self, f"auth_{pid}_client_id", None) + sec = getattr(self, f"auth_{pid}_client_secret", None) + if bool(cid) != bool(sec): + msg = ( + f"AUTH_{pid.upper()}_CLIENT_ID and " + f"AUTH_{pid.upper()}_CLIENT_SECRET must both be set or unset." + ) + raise ValueError(msg) + return self + @model_validator(mode="after") def resolve_database_url(self) -> Self: if self.database_url == _DEFAULT_DATABASE_URL_SENTINEL: self.database_url = f"sqlite:///{self.data_dir_path}/pypsa-app.db" return self + @model_validator(mode="after") + def resolve_ratelimit_enabled(self) -> Self: + if self.ratelimit_enabled is None: + self.ratelimit_enabled = not self.local_mode + return self + @model_validator(mode="after") def validate_local_mode(self) -> Self: - if self.local_mode and self.enable_auth: + if not self.local_mode: + return self + if self.auth_enabled: + msg = "LOCAL_MODE is incompatible with any authentication." + raise ValueError(msg) + if self.snakedispatch_backends: + msg = "SNAKEDISPATCH_BACKENDS is not yet implemented in LOCAL_MODE." + raise ValueError(msg) + return self + + @model_validator(mode="after") + def validate_demo_mode(self) -> Self: + if not self.demo_mode: + return self + if self.auth_oauth_enabled: + msg = "DEMO_MODE is incompatible with OAuth credentials." + raise ValueError(msg) + if self.local_mode: + msg = "DEMO_MODE and LOCAL_MODE are mutually exclusive." + raise ValueError(msg) + if self.snakedispatch_backends: msg = ( - "LOCAL_MODE and ENABLE_AUTH cannot both be true. " - "Local mode is a single-user dashboard deployment." + "DEMO_MODE does not support SNAKEDISPATCH_BACKENDS " + "(no compute backend)." ) raise ValueError(msg) - if self.local_mode and self.snakedispatch_backends: - msg = "SNAKEDISPATCH_BACKENDS is not yet implemented in LOCAL_MODE." + if not self.auth_password_enabled: + msg = "DEMO_MODE requires AUTH_PASSWORD_ENABLED=true." + raise ValueError(msg) + return self + + @model_validator(mode="after") + def validate_password_auth(self) -> Self: + if self.auth_password_enabled and not self.demo_mode: + msg = "AUTH_PASSWORD_ENABLED is only supported with DEMO_MODE." raise ValueError(msg) return self @model_validator(mode="after") def validate_auth_settings(self) -> Self: - if self.enable_auth and self.database_url.startswith("sqlite"): + if self.auth_enabled and self.database_url.startswith("sqlite"): msg = ( "Authentication requires PostgreSQL. " "SQLite does not support the features needed for multi-user auth. " - "Either set ENABLE_AUTH=false or use a PostgreSQL DATABASE_URL." + "Use a PostgreSQL DATABASE_URL or disable authentication." ) raise ValueError(msg) if ( - self.enable_auth + self.auth_enabled and self.session_secret_key == "dev-secret-key-change-in-production" # noqa: S105 ): msg = "Must set a secure SESSION_SECRET_KEY when authentication is enabled" diff --git a/src/pypsa_app/cli.py b/src/pypsa_app/cli.py index b47a295e..a24ab62e 100644 --- a/src/pypsa_app/cli.py +++ b/src/pypsa_app/cli.py @@ -83,11 +83,11 @@ def serve( # Set mode if dev: - os.environ["SERVE_FRONTEND"] = "false" + os.environ["BACKEND_ONLY"] = "true" click.echo(f" API docs: http://{host}:{port}/docs") click.echo(" Start Vite dev server separately: cd frontend && npm run dev") else: - os.environ["SERVE_FRONTEND"] = "true" + os.environ["BACKEND_ONLY"] = "false" click.echo(f" Application: http://{host}:{port}") click.echo(f" API docs: http://{host}:{port}/api/docs") @@ -135,7 +135,6 @@ def open_cmd(file: Path | None, port: int, no_open: bool) -> None: # Settings read the environment at first instantiation, so these have to # be set before any pypsa_app.backend import that touches settings. os.environ["LOCAL_MODE"] = "true" - os.environ["ENABLE_AUTH"] = "false" os.environ["BASE_URL"] = f"http://127.0.0.1:{port}" _require_frontend_built() @@ -196,7 +195,7 @@ def info() -> None: for key in [ "DATA_DIR", "DATABASE_URL", - "SERVE_FRONTEND", + "BACKEND_ONLY", "USE_REDIS", ]: value = os.getenv(key, "(not set)") diff --git a/tests/conftest.py b/tests/conftest.py index c760c2a1..40beb709 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -91,7 +91,9 @@ def _make( url = f"sqlite:///{tmp_path}/test.db" monkeypatch.setattr(settings_module.settings, "database_url", url) monkeypatch.setattr(settings_module.settings, "data_dir", str(tmp_path)) - monkeypatch.setattr(settings_module.settings, "enable_auth", False) + monkeypatch.setattr(settings_module.settings, "auth_github_client_id", None) + monkeypatch.setattr(settings_module.settings, "auth_github_client_secret", None) + monkeypatch.setattr(settings_module.settings, "auth_password_enabled", False) for name, value in settings_overrides.items(): monkeypatch.setattr(settings_module.settings, name, value) run_migrations() diff --git a/tests/test_auth_password.py b/tests/test_auth_password.py new file mode 100644 index 00000000..dceb9b54 --- /dev/null +++ b/tests/test_auth_password.py @@ -0,0 +1,141 @@ +"""Tests for demo-mode password authentication.""" + +import secrets +from pathlib import Path +from uuid import UUID + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import pypsa_app.backend.auth.session as session_module +import pypsa_app.backend.settings as settings_module +from pypsa_app.backend.alembic import run_migrations +from pypsa_app.backend.api.deps import get_db +from pypsa_app.backend.api.routes.auth import router as auth_router +from pypsa_app.backend.auth.password import DEMO_EMAIL, DEMO_PASSWORD +from pypsa_app.backend.models import User, UserRole + + +class _FakeSessionStore: + """In-memory stand-in for the Redis-backed SessionStore.""" + + def __init__(self) -> None: + self._sessions: dict[str, UUID] = {} + + def create_session(self, user_id: UUID) -> str: + sid = secrets.token_urlsafe(16) + self._sessions[sid] = user_id + return sid + + def get_session(self, sid: str) -> UUID | None: + return self._sessions.get(sid) + + def delete_session(self, sid: str) -> bool: + return self._sessions.pop(sid, None) is not None + + +@pytest.fixture +def demo_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """FastAPI app with demo-mode password auth, isolated sqlite DB, and a + fake session store. Yields (app, SessionFactory) so tests can seed the + demo user.""" + url = f"sqlite:///{tmp_path}/auth.db" + monkeypatch.setattr(settings_module.settings, "database_url", url) + monkeypatch.setattr(settings_module.settings, "data_dir", str(tmp_path)) + monkeypatch.setattr(settings_module.settings, "auth_github_client_id", None) + monkeypatch.setattr(settings_module.settings, "auth_github_client_secret", None) + monkeypatch.setattr(settings_module.settings, "auth_password_enabled", True) + monkeypatch.setattr(settings_module.settings, "demo_mode", True) + monkeypatch.setattr(session_module, "session_store", _FakeSessionStore()) + + run_migrations() + + engine = create_engine(url) + Session = sessionmaker(bind=engine, autoflush=False) + + app = FastAPI() + app.include_router(auth_router, prefix="/auth") + + def _override_db(): + s = Session() + try: + yield s + finally: + s.close() + + app.dependency_overrides[get_db] = _override_db + + try: + yield app, Session + finally: + engine.dispose() + + +def _seed_demo_user(Session) -> None: + with Session() as db: + db.add(User(username="demo", role=UserRole.ADMIN)) + db.commit() + + +def test_demo_login_success(demo_app) -> None: + app, Session = demo_app + _seed_demo_user(Session) + + with TestClient(app) as client: + r = client.post( + "/auth/login/password", + json={"email": DEMO_EMAIL, "password": DEMO_PASSWORD}, + ) + assert r.status_code == 200, r.text + assert "pypsa_session" in r.cookies + + +def test_demo_login_wrong_password(demo_app) -> None: + app, Session = demo_app + _seed_demo_user(Session) + + with TestClient(app) as client: + r = client.post( + "/auth/login/password", + json={"email": DEMO_EMAIL, "password": "WRONG-pw"}, + ) + assert r.status_code == 401 + assert "pypsa_session" not in r.cookies + + +def test_demo_login_wrong_email(demo_app) -> None: + app, Session = demo_app + _seed_demo_user(Session) + + with TestClient(app) as client: + r = client.post( + "/auth/login/password", + json={"email": "nobody@example.com", "password": DEMO_PASSWORD}, + ) + assert r.status_code == 401 + + +def test_demo_login_disabled_returns_400( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + url = f"sqlite:///{tmp_path}/auth_off.db" + monkeypatch.setattr(settings_module.settings, "database_url", url) + monkeypatch.setattr(settings_module.settings, "data_dir", str(tmp_path)) + monkeypatch.setattr(settings_module.settings, "auth_github_client_id", None) + monkeypatch.setattr(settings_module.settings, "auth_github_client_secret", None) + monkeypatch.setattr(settings_module.settings, "auth_password_enabled", False) + monkeypatch.setattr(settings_module.settings, "demo_mode", False) + run_migrations() + + app = FastAPI() + app.include_router(auth_router, prefix="/auth") + + with TestClient(app) as client: + r = client.post( + "/auth/login/password", + json={"email": DEMO_EMAIL, "password": DEMO_PASSWORD}, + ) + assert r.status_code == 400 diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py new file mode 100644 index 00000000..c052c7cc --- /dev/null +++ b/tests/test_rate_limit.py @@ -0,0 +1,196 @@ +"""Tests for slowapi-backed per-route rate limiting.""" + +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient +from slowapi import Limiter +from slowapi.errors import RateLimitExceeded + +import pypsa_app.backend.ratelimit as ratelimit_module +import pypsa_app.backend.settings as settings_module +from pypsa_app.backend.ratelimit import ( + get_client_ip, + rate_limit_exceeded_handler, + rate_limit_key, +) +from pypsa_app.backend.settings import Settings + +_SERVER_ENV = { + "LOCAL_MODE": "false", + "REDIS_URL": "redis://localhost:6379/0", + "DATABASE_URL": "postgresql://x/y", + "SESSION_SECRET_KEY": "x" * 40, + "AUTH_PASSWORD_ENABLED": "true", + "DEMO_MODE": "true", +} + + +@pytest.mark.parametrize( + ("env", "expected"), + [ + (_SERVER_ENV, True), + ({"LOCAL_MODE": "true"}, False), + ({**_SERVER_ENV, "RATELIMIT_ENABLED": "false"}, False), + ], + ids=["server", "local", "override"], +) +def test_ratelimit_enabled( + monkeypatch: pytest.MonkeyPatch, env: dict[str, str], expected: bool +) -> None: + for k, v in env.items(): + monkeypatch.setenv(k, v) + assert Settings().ratelimit_enabled is expected + + +def _fake_request( + *, + client_host: str | None = "127.0.0.1", + cf_ip: str | None = None, + user: object | None = None, +) -> Request: + """Build a minimal Request stand-in for get_client_ip / rate_limit_key.""" + headers = {"cf-connecting-ip": cf_ip} if cf_ip else {} + state = SimpleNamespace(user=user) + client = SimpleNamespace(host=client_host) if client_host else None + # Cast to Request for the typechecker; the functions only touch the + # attributes set here. + return SimpleNamespace(headers=headers, state=state, client=client) # type: ignore[return-value] + + +@pytest.mark.parametrize( + ("trust_cf", "cf_ip", "client_host", "expected"), + [ + (True, "1.2.3.4", "10.0.0.1", "1.2.3.4"), + (True, None, "10.0.0.1", "10.0.0.1"), + (False, "1.2.3.4", "10.0.0.1", "10.0.0.1"), + (False, None, None, "unknown"), + ], + ids=["cf-trusted", "cf-trusted-missing", "cf-untrusted", "no-client"], +) +def test_get_client_ip( + monkeypatch: pytest.MonkeyPatch, + trust_cf: bool, + cf_ip: str | None, + client_host: str | None, + expected: str, +) -> None: + monkeypatch.setattr(settings_module.settings, "trust_cloudflare_ip", trust_cf) + req = _fake_request(client_host=client_host, cf_ip=cf_ip) + assert get_client_ip(req) == expected + + +_ALICE = SimpleNamespace(id="user-uuid", username="alice") +_DEMO = SimpleNamespace(id="demo-uuid", username="demo") + + +@pytest.mark.parametrize( + ("user", "cf_ip", "client_host", "trust_cf", "expected"), + [ + (_ALICE, None, "10.0.0.1", False, "user:user-uuid"), + # Shared `demo` account must not get a per-user bucket — bucket by IP. + (_DEMO, "1.1.1.1", None, True, "ip:1.1.1.1"), + (None, None, "10.0.0.9", False, "ip:10.0.0.9"), + ], + ids=["user", "demo", "anon"], +) +def test_rate_limit_key( + monkeypatch: pytest.MonkeyPatch, + user: object | None, + cf_ip: str | None, + client_host: str | None, + trust_cf: bool, + expected: str, +) -> None: + monkeypatch.setattr(settings_module.settings, "trust_cloudflare_ip", trust_cf) + req = _fake_request(user=user, client_host=client_host, cf_ip=cf_ip) + assert rate_limit_key(req) == expected + + +def _make_rl_app(monkeypatch: pytest.MonkeyPatch, limit: str) -> FastAPI: + """Minimal FastAPI app exercising slowapi end-to-end with a fresh Limiter.""" + monkeypatch.setattr(settings_module.settings, "trust_cloudflare_ip", True) + test_limiter = Limiter( + key_func=rate_limit_key, + storage_uri="memory://", + enabled=True, + swallow_errors=True, + headers_enabled=True, + strategy="moving-window", + ) + # Reuse the production exception handler — it pulls the limiter off + # `request.app.state.limiter`, so the test app must register the test one. + monkeypatch.setattr(ratelimit_module, "limiter", test_limiter) + + app = FastAPI() + app.state.limiter = test_limiter + app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) + + @app.middleware("http") + async def _attach_user(request, call_next): # noqa: ANN001 + request.state.user = None + return await call_next(request) + + @app.post("/route") + @test_limiter.limit(limit) + async def route(request: Request) -> JSONResponse: # noqa: ARG001 + # Return a Response (not a dict) so slowapi can inject rate-limit + # headers without erroring. + return JSONResponse({"ok": True}) + + return app + + +def test_429_threshold_and_headers(monkeypatch: pytest.MonkeyPatch) -> None: + """N requests under limit pass, N+1th returns 429 with rate-limit headers.""" + limit = 5 + app = _make_rl_app(monkeypatch, f"{limit}/minute") + with TestClient(app) as client: + headers = {"CF-Connecting-IP": "10.0.0.1"} + responses = [client.post("/route", headers=headers) for _ in range(limit + 1)] + codes = [r.status_code for r in responses] + assert codes == [200] * limit + [429], codes + last = responses[-1] + assert last.json()["detail"].startswith("Too many requests.") + assert "retry-after" in {h.lower() for h in last.headers} + assert any(h.lower().startswith("x-ratelimit-") for h in last.headers) + + +def test_per_ip_isolation_via_cf_header(monkeypatch: pytest.MonkeyPatch) -> None: + """Different CF-Connecting-IP values are separate buckets.""" + app = _make_rl_app(monkeypatch, "5/minute") + with TestClient(app) as client: + codes_a = [ + client.post("/route", headers={"CF-Connecting-IP": "10.0.0.3"}).status_code + for _ in range(6) + ] + # Fresh IP, fresh bucket. + code_b = client.post( + "/route", headers={"CF-Connecting-IP": "10.0.0.4"} + ).status_code + assert codes_a == [200] * 5 + [429], codes_a + assert code_b == 200 + + +def test_expensive_routes_have_decorator() -> None: + """Plots and statistics routes are registered with the expensive tier.""" + # Importing the route modules registers the decorators on the shared limiter. + from pypsa_app.backend.api.routes import plots, statistics # noqa: F401 + + # The expensive tier default is "60/minute;600/hour" — two limits per route. + assert Settings().ratelimit_expensive == "60/minute;600/hour" + + expected = { + "pypsa_app.backend.api.routes.plots.generate_plot", + "pypsa_app.backend.api.routes.plots.generate_explore", + "pypsa_app.backend.api.routes.statistics.get_statistics", + } + registered = set(ratelimit_module.limiter._route_limits) + assert expected <= registered, expected - registered + + n_expected = len(settings_module.settings.ratelimit_expensive.split(";")) + for qualname in expected: + limits = ratelimit_module.limiter._route_limits[qualname] + assert len(limits) == n_expected, (qualname, [str(li.limit) for li in limits]) diff --git a/uv.lock b/uv.lock index 452331fb..a8deeb52 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-05-05T16:06:41.153419917Z" +exclude-newer = "2026-05-12T21:02:34.636004062Z" exclude-newer-span = "P7D" [[package]] @@ -74,15 +74,15 @@ wheels = [ [[package]] name = "authlib" -version = "1.7.1" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/f2/e05664d5275ce811fd4e9df0a2b3f0086ee19a8a80358d95499fa82fd50c/authlib-1.7.1.tar.gz", hash = "sha256:8c09b0f9d080c823e594b52316af70f79a1fa4eed64d0363a076233c04ef063a", size = 175884, upload-time = "2026-05-04T08:11:25.033Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/82/730650ee5e5b598b7bfdc291b784bc2f6fe02a5671695485403365101088/authlib-1.7.1-py2.py3-none-any.whl", hash = "sha256:8470f4aa6b5590ac41bd81d6e6ee12448ce36a0da0af19bbed69fb53fb4e8ad9", size = 258826, upload-time = "2026-05-04T08:11:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] [[package]] @@ -243,63 +243,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/80/4ecbda8318fbf40ad4e005a4a93aebba69e81382e5b4c6086251cd5d0ee8/cftime-1.6.5-cp314-cp314t-win_arm64.whl", hash = "sha256:034c15a67144a0a5590ef150c99f844897618b148b87131ed34fda7072614662", size = 469065, upload-time = "2026-01-02T20:45:23.398Z" }, ] -[[package]] -name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, -] - [[package]] name = "click" version = "8.3.3" @@ -424,71 +367,71 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, + { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, + { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, ] [[package]] @@ -571,6 +514,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl", hash = "sha256:be614b9242b0b38288060fb2d7696125946469c98a1c30e174883fd199e0428d", size = 1485630, upload-time = "2026-03-18T07:10:12.832Z" }, ] +[[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 = "deprecation" version = "2.1.0" @@ -583,6 +538,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "fastapi" version = "0.136.1" @@ -658,107 +635,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/78/6a04792ace63a93e162f1305392d500ae8ddcb620e7eb88a22fd622b35bb/geopandas-1.1.3-py3-none-any.whl", hash = "sha256:90d62a64f95eaa3be2ccc115c5f3d6e24208bb11983b390fdc0621a3eccd0230", size = 342514, upload-time = "2026-03-09T21:49:07.973Z" }, ] -[[package]] -name = "google-api-core" -version = "2.30.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "googleapis-common-protos" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, -] - -[[package]] -name = "google-auth" -version = "2.50.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/18/238d7021d151bdab868f23433817b027dd759135202f4dfce0670d1230ca/google_auth-2.50.0.tar.gz", hash = "sha256:f35eafb191195328e8ce10a7883970877e7aeb49c2bfaa54aa0e394316d353d0", size = 336523, upload-time = "2026-04-30T21:19:29.659Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/cf/4880c2137c14280b2f59975cdf12cc442bc0ae1f9ea473a26eaa0c146786/google_auth-2.50.0-py3-none-any.whl", hash = "sha256:04382175e28b94f49694977f0a792688b59a668def1499e9d8de996dc9ce5b15", size = 246495, upload-time = "2026-04-30T21:19:27.664Z" }, -] - -[[package]] -name = "google-cloud-core" -version = "2.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/24/6ca08b0a03c7b0c620427503ab00353a4ae806b848b93bcea18b6b76fde6/google_cloud_core-2.5.1.tar.gz", hash = "sha256:3dc94bdec9d05a31d9f355045ed0f369fbc0d8c665076c734f065d729800f811", size = 36078, upload-time = "2026-03-30T22:50:08.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" }, -] - -[[package]] -name = "google-cloud-storage" -version = "3.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-crc32c" }, - { name = "google-resumable-media" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/ff/ca9ab2417fa913d75aae38bf40bf856bb2749a604b2e0f701b37cfcd23cc/google_cloud_storage-3.10.1-py3-none-any.whl", hash = "sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f", size = 324453, upload-time = "2026-03-23T09:35:21.368Z" }, -] - -[[package]] -name = "google-crc32c" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, - { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, - { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, - { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, - { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, - { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, - { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, - { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, -] - -[[package]] -name = "google-resumable-media" -version = "2.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-crc32c" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/d1/b1ea14b93b6b78f57fc580125de44e9f593ab88dd2460f1a8a8d18f74754/google_resumable_media-2.8.2.tar.gz", hash = "sha256:f3354a182ebd193ae3f42e3ef95e6c9b10f128320de23ac7637236713b1acd70", size = 2164510, upload-time = "2026-03-30T23:34:25.369Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f8/50bfaf4658431ff9de45c5c3935af7ab01157a4903c603cd0eee6e78e087/google_resumable_media-2.8.2-py3-none-any.whl", hash = "sha256:82b6d8ccd11765268cdd2a2123f417ec806b8eef3000a9a38dfe3033da5fb220", size = 81511, upload-time = "2026-03-30T23:34:09.671Z" }, -] - -[[package]] -name = "googleapis-common-protos" -version = "1.74.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, -] - [[package]] name = "greenlet" version = "3.5.0" @@ -882,11 +758,11 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, ] [[package]] @@ -921,14 +797,14 @@ wheels = [ [[package]] name = "joserfc" -version = "1.6.4" +version = "1.6.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/c6/de8fdbdfa75c8ca04fead38a82d573df8a82906e984c349d58665f459558/joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c", size = 231866, upload-time = "2026-04-13T13:15:40.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/f7/210b27752e972edb36d239315b08d3eb6b14824cc4a590da2337d195260b/joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a", size = 70464, upload-time = "2026-04-13T13:15:39.259Z" }, + { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, ] [[package]] @@ -1073,28 +949,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/5e/3fb67e882c1fee01ebb7abc1c0a6669e5ff8acd060e93bfe7229e9ce6e4f/levenshtein-0.27.3-cp314-cp314t-win_arm64.whl", hash = "sha256:1d8520b89b7a27bb5aadbcc156715619bcbf556a8ac46ad932470945dca6e1bd", size = 91020, upload-time = "2025-11-01T12:14:22.944Z" }, ] +[[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 = "linopy" -version = "0.6.7" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bottleneck" }, { name = "dask" }, { name = "deprecation" }, - { name = "google-cloud-storage" }, { name = "numexpr" }, { name = "numpy" }, { name = "packaging" }, { name = "polars" }, - { name = "requests" }, { name = "scipy" }, { name = "toolz" }, { name = "tqdm" }, { name = "xarray" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/31/cc01d01f9f54946005e42ad6b057517322e501292813c6a5d6b751d64359/linopy-0.6.7.tar.gz", hash = "sha256:3e41ec13ca7ca551ad9e3a8a3bc1a7e388222d5a140c4c5bd27d0ddd16b4cf22", size = 1269656, upload-time = "2026-04-21T07:47:29.311Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/1a/d7ba832537f24b7833cc1bd612adfb327629b217b28fc1891a420fbe317a/linopy-0.7.0.tar.gz", hash = "sha256:a00a66258e6c2db473b1e6817c677f6c7a5bcb18e4351ae9ddf9713c754a7a44", size = 1375174, upload-time = "2026-05-11T05:24:55.621Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/4f/328c83515ff774193fce4968f00ef52fb6f907a77cf528cee9db7e83eabf/linopy-0.6.7-py3-none-any.whl", hash = "sha256:bdab81cede18eb2e1ce4f17a8ccc05699e1516500213125e8ca0199a3131f4f9", size = 117045, upload-time = "2026-04-21T07:47:27.635Z" }, + { url = "https://files.pythonhosted.org/packages/13/ff/4210832258cfcb4f9a17ab58965e1ab53d8e0c5e99013a10c14f201de00f/linopy-0.7.0-py3-none-any.whl", hash = "sha256:d35fc479265d5c289b49c04ccd18b78c1b97b5dfbb37265f40202672adbd87e1", size = 147847, upload-time = "2026-05-11T05:24:53.831Z" }, ] [[package]] @@ -1219,11 +1107,11 @@ wheels = [ [[package]] name = "narwhals" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/f3/257adc69a71011b4c8cda321b00f02c5bf1980ae38ffd05a58d9632d4de8/narwhals-2.20.0.tar.gz", hash = "sha256:c10994975fa7dc5a68c2cffcddbd5908fc8ebb2d463c5bab085309c0ee1f551e", size = 627848, upload-time = "2026-04-20T12:11:45.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/0e/3ad61eb87088cc4932e0d851531fa82f845a6230b68b091a0e298cc7e537/narwhals-2.21.0.tar.gz", hash = "sha256:7c6e7f50528e62b7a967dd864d7e117d2955d38d4f730653ce46a9861358e2dc", size = 633083, upload-time = "2026-05-08T12:29:02.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e1/68c2256b69a314eba133673377ba9118c356f6342a0c02b61de449cf2bf2/narwhals-2.21.0-py3-none-any.whl", hash = "sha256:1e6617d0fca68ae1fda29e5397c4eaacd3ffc9fffe6bcd6ded0c690475e853be", size = 451943, upload-time = "2026-05-08T12:29:01.058Z" }, ] [[package]] @@ -1366,46 +1254,46 @@ wheels = [ [[package]] name = "pandas" -version = "3.0.2" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, - { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, - { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, - { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, - { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, - { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, - { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, - { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, - { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, - { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, - { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, - { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, - { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, - { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, - { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, - { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, - { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, - { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, - { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, - { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, - { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, - { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, - { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, - { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, - { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, - { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, - { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] [[package]] @@ -1550,33 +1438,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] -[[package]] -name = "proto-plus" -version = "1.27.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" }, -] - -[[package]] -name = "protobuf" -version = "7.34.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, - { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, - { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, - { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, - { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, - { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, - { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, -] - [[package]] name = "psycopg2-binary" version = "2.9.12" @@ -1607,27 +1468,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, ] -[[package]] -name = "pyasn1" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -1639,7 +1479,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.3" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1647,79 +1487,84 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, ] [[package]] name = "pydantic-core" -version = "2.46.3" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, - { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, - { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, - { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, - { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, - { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, - { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, - { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, - { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, - { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, - { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, - { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] [[package]] name = "pydantic-settings" -version = "2.14.0" +version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] [[package]] @@ -1880,10 +1725,11 @@ dependencies = [ { name = "markupsafe" }, { name = "pandas" }, { name = "platformdirs" }, - { name = "pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "pypsa" }, { name = "python-multipart" }, + { name = "slowapi" }, { name = "sqlalchemy" }, { name = "starlette" }, { name = "uvicorn", extra = ["standard"] }, @@ -1906,7 +1752,7 @@ full = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.18" }, - { name = "authlib", specifier = ">=1.7,<2" }, + { name = "authlib", specifier = ">=1.7.2,<2" }, { name = "celery", extras = ["redis"], marker = "extra == 'full'", specifier = ">=5.6,<6" }, { name = "click", specifier = ">=8.3" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6,<8" }, @@ -1918,7 +1764,7 @@ requires-dist = [ { name = "pandas", specifier = ">=3.0,<4" }, { name = "platformdirs", specifier = ">=4" }, { name = "psycopg2-binary", marker = "extra == 'full'", specifier = ">=2.9" }, - { name = "pydantic", specifier = ">=2.13,<3" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.13,<3" }, { name = "pydantic-settings", specifier = ">=2.14,<3" }, { name = "pypsa", specifier = ">=1.2,<2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0,<10" }, @@ -1927,6 +1773,7 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0,<8" }, { name = "python-multipart", specifier = ">=0.0.27" }, { name = "redis", marker = "extra == 'full'", specifier = ">=6.4,<7" }, + { name = "slowapi", specifier = ">=0.1.9,<0.2" }, { name = "sqlalchemy", specifier = ">=2.0,<3" }, { name = "starlette", specifier = ">=1.0,<2" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.46" }, @@ -2012,11 +1859,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.27" +version = "0.0.28" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" }, ] [[package]] @@ -2116,21 +1963,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, ] -[[package]] -name = "requests" -version = "2.33.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, -] - [[package]] name = "scipy" version = "1.17.1" @@ -2248,6 +2080,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 = "sqlalchemy" version = "2.0.49" @@ -2362,15 +2206,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - [[package]] name = "uvicorn" version = "0.46.0" @@ -2541,6 +2376,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] +[[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/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 = "xarray" version = "2026.2.0"