From 02c62b49b8c40dca89e544a69b076f1244370b63 Mon Sep 17 00:00:00 2001 From: polymood <36277904+polymood@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:49:30 +0200 Subject: [PATCH 1/4] feat: deep memory engine (memray) + TimescaleDB + realtime WebSocket Fuse memray-deep allocation tracing with the Dask task graph and stream it live. - deepmem: memray driven as a library, epoch-rotated, folded to the first user source line; captures per-line high-water marks and full call stacks (schema v6). Opt-in via deep=True on register()/LocalProfiler; degrades to Tier-1 sampling where memray isn't importable. Never crashes or OOMs the job. - Worker plugin emits live WorkerStatus heartbeats + deep epochs; scheduler plugin death events joined with chunk metadata and allocation lines. - Collector: TimescaleDB backend (hypertables) as default behind a StoreProtocol, SQLite kept for tests/dev; asyncio pub/sub hub + /ws WebSocket fan-out; per-line, per-task, per-layer-timeline, flamegraph and worker endpoints. - Runs record origin hostname + IP so a shared collector is a team hub. - Source attribution reads the full multi-line statement, not just one line. --- docker-compose.yml | 22 + pyproject.toml | 46 +- src/daskgenie/client.py | 10 + src/daskgenie/collector/__main__.py | 11 +- src/daskgenie/collector/app.py | 127 ++- src/daskgenie/collector/hub.py | 72 ++ src/daskgenie/collector/store.py | 414 +++++++++- src/daskgenie/collector/store_tsdb.py | 781 ++++++++++++++++++ src/daskgenie/common/schemas.py | 105 ++- src/daskgenie/deepmem/__init__.py | 7 + src/daskgenie/deepmem/tracker.py | 349 ++++++++ src/daskgenie/graphcapture/__init__.py | 9 +- src/daskgenie/graphcapture/capture.py | 47 +- src/daskgenie/graphcapture/extract.py | 11 +- src/daskgenie/local_profiler.py | 109 ++- src/daskgenie/report.py | 10 +- src/daskgenie/worker_plugin/plugin.py | 133 ++- tests/test_collector.py | 130 +++ tests/test_integration_deep.py | 93 +++ uv.lock | 1036 +++++++++++++++++++++++- 20 files changed, 3452 insertions(+), 70 deletions(-) create mode 100644 src/daskgenie/collector/hub.py create mode 100644 src/daskgenie/collector/store_tsdb.py create mode 100644 src/daskgenie/deepmem/__init__.py create mode 100644 src/daskgenie/deepmem/tracker.py create mode 100644 tests/test_integration_deep.py diff --git a/docker-compose.yml b/docker-compose.yml index a74667d..11b041d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,12 +4,33 @@ # # docker compose up -d --build services: + timescaledb: + image: timescale/timescaledb:latest-pg16 + environment: + - POSTGRES_USER=daskgenie + - POSTGRES_PASSWORD=daskgenie + - POSTGRES_DB=daskgenie + volumes: + - daskgenie-tsdb:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U daskgenie"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + collector: build: . + environment: + - DASKGENIE_HOST=0.0.0.0 + - DASKGENIE_DSN=postgresql://daskgenie:daskgenie@timescaledb:5432/daskgenie ports: - "8765:8765" volumes: - daskgenie-data:/data + depends_on: + timescaledb: + condition: service_healthy restart: unless-stopped web: @@ -24,3 +45,4 @@ services: volumes: daskgenie-data: + daskgenie-tsdb: diff --git a/pyproject.toml b/pyproject.toml index c396df2..2483929 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,23 +2,62 @@ name = "daskgenie" version = "0.1.0" description = "A code-to-graph-to-memory profiler for Dask: know which chunk killed a worker and what source line produced it." +readme = "README.md" requires-python = ">=3.11" +license = { text = "MIT" } +authors = [{ name = "polymood", email = "36277904+polymood@users.noreply.github.com" }] +keywords = ["dask", "profiler", "memory", "memray", "flamegraph", "distributed", "oom"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Debuggers", + "Topic :: System :: Monitoring", +] dependencies = [ "dask>=2024.1.0", "pydantic>=2.6", "psutil>=5.9", ] +[project.urls] +Homepage = "https://github.com/polymood/DaskGenie" +Repository = "https://github.com/polymood/DaskGenie" +Issues = "https://github.com/polymood/DaskGenie/issues" + [project.optional-dependencies] collector = [ "fastapi>=0.110", - "uvicorn>=0.29", + "uvicorn[standard]>=0.29", "prometheus-client>=0.20", + "websockets>=12.0", + "psycopg[binary]>=3.1", +] +# Deep memory profiling (memray as a library). Linux/macOS + CPython only; the +# profiler degrades to Tier-1 sampling where memray isn't importable. +deep = [ + "memray>=1.11", ] demo = [ "distributed>=2024.1.0", "numpy>=1.26", ] +# Broader Dask surface for the example scripts: dataframes, bags, xarray on +# Zarr/NetCDF. Kept separate so the core install stays light. +examples = [ + "distributed>=2024.1.0", + "numpy>=1.26", + "pandas>=2.2", + "pyarrow>=15", + "xarray>=2024.1", + "zarr>=2.17", + "h5netcdf>=1.3", + "h5py>=3.10", + "scipy>=1.12", +] [dependency-groups] dev = [ @@ -30,8 +69,11 @@ dev = [ "distributed>=2024.1.0", "numpy>=1.26", "fastapi>=0.110", - "uvicorn>=0.29", + "uvicorn[standard]>=0.29", "prometheus-client>=0.20", + "websockets>=12.0", + "psycopg[binary]>=3.1", + "memray>=1.11", "jinja2>=3.1", ] diff --git a/src/daskgenie/client.py b/src/daskgenie/client.py index 62c57c4..1f81c49 100644 --- a/src/daskgenie/client.py +++ b/src/daskgenie/client.py @@ -21,6 +21,8 @@ def register( run_name: str = "", sample_interval: float = 0.2, flush_interval: float = 0.5, + deep: bool = False, + deep_epoch_seconds: float = 5.0, ) -> str: """Open a run and install both profiler plugins on the cluster. @@ -31,6 +33,12 @@ def register( ``flush_interval`` bounds how long chunk metadata can sit unsent on a worker; keep it well under how fast your workers OOM, or the killer chunk's metadata dies with the process before it is pushed. + + ``deep=True`` enables the memray-backed deep memory engine on each worker: + per-source-line high-water-mark attribution rotated every + ``deep_epoch_seconds``. It costs ~1.5-2x runtime and needs the ``deep`` + extra (memray, Linux/macOS + CPython); where memray isn't importable the + worker silently degrades to the always-on Tier-1 sampling. """ run_id = create_run(collector_url, run_name) client.register_plugin( @@ -39,6 +47,8 @@ def register( run_id, sample_interval=sample_interval, flush_interval=flush_interval, + deep=deep, + deep_epoch_seconds=deep_epoch_seconds, ) ) client.register_plugin(DeathAttributionPlugin(collector_url, run_id)) diff --git a/src/daskgenie/collector/__main__.py b/src/daskgenie/collector/__main__.py index 68ec8bb..b3f1ba8 100644 --- a/src/daskgenie/collector/__main__.py +++ b/src/daskgenie/collector/__main__.py @@ -8,7 +8,7 @@ import uvicorn from daskgenie.collector.app import create_app -from daskgenie.collector.store import Store +from daskgenie.collector.store import make_store def main() -> None: @@ -16,13 +16,18 @@ def main() -> None: parser.add_argument( "--db", default=os.environ.get("DASKGENIE_DB", "daskgenie.db"), - help="SQLite file (or :memory:)", + help="SQLite file (or :memory:); ignored when DASKGENIE_DSN is set", + ) + parser.add_argument( + "--dsn", + default=os.environ.get("DASKGENIE_DSN"), + help="Postgres/TimescaleDB DSN; selects the Timescale backend when set", ) parser.add_argument("--host", default=os.environ.get("DASKGENIE_HOST", "127.0.0.1")) parser.add_argument("--port", type=int, default=int(os.environ.get("DASKGENIE_PORT", "8765"))) args = parser.parse_args() - app = create_app(Store(args.db)) + app = create_app(make_store(args.dsn, args.db)) uvicorn.run(app, host=args.host, port=args.port) diff --git a/src/daskgenie/collector/app.py b/src/daskgenie/collector/app.py index 77cf356..38c1e3a 100644 --- a/src/daskgenie/collector/app.py +++ b/src/daskgenie/collector/app.py @@ -9,9 +9,13 @@ from __future__ import annotations +import asyncio +import contextlib +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from typing import Any -from fastapi import FastAPI, HTTPException, Response +from fastapi import FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from prometheus_client import ( CONTENT_TYPE_LATEST, @@ -21,7 +25,8 @@ generate_latest, ) -from daskgenie.collector.store import Store +from daskgenie.collector.hub import Hub +from daskgenie.collector.store import StoreProtocol, make_store from daskgenie.common.schemas import ( SCHEMA_VERSION, DeathEvent, @@ -32,8 +37,18 @@ ) -def create_app(store: Store | None = None) -> FastAPI: - app = FastAPI(title="DaskGenie Collector", version="0.1.0") +def create_app(store: StoreProtocol | None = None) -> FastAPI: + store = store or make_store() + hub = Hub() + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + # Bind the serving loop so the sync ingest threads can hand live events + # to the WebSocket subscribers. + hub.bind_loop(asyncio.get_running_loop()) + yield + + app = FastAPI(title="DaskGenie Collector", version="0.1.0", lifespan=lifespan) # The dashboard is a separate Next.js app, so allow it to call the API from # its own origin during local dev / split deployments. app.add_middleware( @@ -42,7 +57,6 @@ def create_app(store: Store | None = None) -> FastAPI: allow_methods=["*"], allow_headers=["*"], ) - store = store or Store() # A private registry (not the global default) so repeated create_app calls # in tests don't raise "Duplicated timeseries" on re-registration. @@ -81,6 +95,21 @@ def ingest_samples(batch: SampleBatch) -> dict[str, int]: for s in batch.samples: rss_gauge.labels(batch.worker).set(s.rss_bytes) managed_gauge.labels(batch.worker).set(s.managed_bytes) + # Live fan-out: the just-ingested slice, so the dashboard updates without + # polling. Frontend selects the streams it needs from this envelope. + if batch.samples or batch.spans or batch.statuses or batch.epochs or batch.task_memory: + hub.publish( + batch.run_id, + { + "type": "batch", + "worker": batch.worker, + "samples": [s.model_dump() for s in batch.samples], + "spans": [s.model_dump() for s in batch.spans], + "statuses": [s.model_dump() for s in batch.statuses], + "epochs": [e.model_dump() for e in batch.epochs], + "task_memory": [t.model_dump() for t in batch.task_memory], + }, + ) return {"samples": len(batch.samples), "chunks": len(batch.chunks)} @app.post("/ingest/graph") @@ -88,6 +117,7 @@ def ingest_graph(upload: GraphUpload) -> dict[str, int]: _reject_stale(upload.schema_version) store.ensure_run(upload.run_id) store.add_graph(upload) + hub.publish(upload.run_id, {"type": "graph"}) return {"layers": len(upload.layers)} @app.post("/ingest/death") @@ -98,10 +128,15 @@ def ingest_death(event: DeathEvent) -> dict[str, str]: # were captured worker-side and already live here. Join them now so the # stored post-mortem answers "which chunk killed this worker" directly. enriched = list(event.suspect_chunks) + sites = list(event.suspect_sites) for key in event.suspect_keys: enriched.extend(store.chunks_for(event.run_id, key)) - store.add_death(event.model_copy(update={"suspect_chunks": enriched})) + sites.extend(store.sites_for(event.run_id, key)) + stored = event.model_copy(update={"suspect_chunks": enriched, "suspect_sites": sites}) + store.add_death(stored) deaths_counter.inc() + hub.publish(event.run_id, {"type": "death", "data": stored.model_dump()}) + hub.publish(RUNS_CHANNEL, {"type": "runs"}) # death changes the run's sidebar counts return {"status": "recorded"} @app.get("/metrics") @@ -115,9 +150,18 @@ def metrics() -> Response: # -- run management ----------------------------------------------------- + # A fixed channel the sidebar subscribes to so the run list updates live + # (a run appearing, a death changing its counts) without polling. + RUNS_CHANNEL = "__runs__" + @app.post("/api/runs") - def create_run(body: RunCreate) -> RunInfo: - return store.create_run(body.name) + def create_run(body: RunCreate, request: Request) -> RunInfo: + # X-Forwarded-For first (behind a proxy), else the direct peer. + fwd = request.headers.get("x-forwarded-for", "") + ip = fwd.split(",")[0].strip() if fwd else (request.client.host if request.client else "") + run = store.create_run(body.name, origin=body.origin, origin_ip=ip) + hub.publish(RUNS_CHANNEL, {"type": "runs"}) + return run @app.get("/api/runs") def list_runs() -> list[RunInfo]: @@ -132,7 +176,10 @@ def get_run(run_id: str) -> RunInfo: @app.delete("/api/runs/{run_id}") def delete_run(run_id: str) -> dict[str, bool]: - return {"deleted": store.delete_run(run_id)} + deleted = store.delete_run(run_id) + if deleted: + hub.publish(RUNS_CHANNEL, {"type": "runs"}) + return {"deleted": deleted} # -- run-scoped query --------------------------------------------------- @@ -160,4 +207,66 @@ def spans(run_id: str) -> list[dict[str, Any]]: def layer_stats(run_id: str) -> list[dict[str, Any]]: return store.layer_stats(run_id) + @app.get("/api/runs/{run_id}/alloc-sites") + def alloc_sites( + run_id: str, start: float | None = None, end: float | None = None + ) -> list[dict[str, Any]]: + return store.alloc_sites(run_id, start=start, end=end) + + @app.get("/api/runs/{run_id}/task-memory") + def task_memory(run_id: str) -> list[dict[str, Any]]: + return store.task_memory(run_id) + + @app.get("/api/runs/{run_id}/alloc-timeline") + def alloc_timeline(run_id: str) -> list[dict[str, Any]]: + return store.alloc_timeline(run_id) + + @app.get("/api/runs/{run_id}/flamegraph") + def flamegraph( + run_id: str, + worker: str | None = None, + start: float | None = None, + end: float | None = None, + ) -> dict[str, Any]: + return store.flamegraph(run_id, worker, start, end) + + @app.get("/api/runs/{run_id}/workers") + def workers(run_id: str) -> list[dict[str, Any]]: + return store.worker_status(run_id) + + # -- live stream -------------------------------------------------------- + + @app.websocket("/ws/runs") + async def ws_runs(ws: WebSocket) -> None: + """Global channel: pushes a nudge whenever the run list changes, so the + sidebar refreshes in real time instead of polling.""" + await ws.accept() + q = hub.subscribe(RUNS_CHANNEL) + try: + while True: + await ws.send_json(await q.get()) + except WebSocketDisconnect: + pass + except Exception: # noqa: BLE001 + with contextlib.suppress(Exception): + await ws.close() + finally: + hub.unsubscribe(RUNS_CHANNEL, q) + + @app.websocket("/ws/runs/{run_id}") + async def ws_run(ws: WebSocket, run_id: str) -> None: + await ws.accept() + q = hub.subscribe(run_id) + try: + while True: + event = await q.get() + await ws.send_json(event) + except WebSocketDisconnect: + pass + except Exception: # noqa: BLE001 - a broken socket must not crash serving + with contextlib.suppress(Exception): + await ws.close() + finally: + hub.unsubscribe(run_id, q) + return app diff --git a/src/daskgenie/collector/hub.py b/src/daskgenie/collector/hub.py new file mode 100644 index 0000000..8149f0e --- /dev/null +++ b/src/daskgenie/collector/hub.py @@ -0,0 +1,72 @@ +"""In-process pub/sub bridging sync ingest to async WebSocket subscribers. + +Ingest endpoints run in FastAPI's threadpool (they're ``def``, not ``async``); +the ``/ws`` endpoint runs on the event loop. So ``publish`` is called from a +worker thread and must hand the event to the loop safely +(``call_soon_threadsafe``), which then fans it out to each subscriber's +``asyncio.Queue``. Queues are bounded: a slow browser drops its oldest frame +rather than growing the collector's memory — the same "never be the thing that +OOMs" discipline the plugins follow. +""" + +from __future__ import annotations + +import asyncio +import threading +from typing import Any + + +class Hub: + def __init__(self, max_queue: int = 2000) -> None: + self._subs: dict[str, set[asyncio.Queue[dict[str, Any]]]] = {} + self._lock = threading.Lock() + self._loop: asyncio.AbstractEventLoop | None = None + self._max_queue = max_queue + + def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None: + """Record the serving event loop (called from FastAPI startup).""" + self._loop = loop + + def subscribe(self, run_id: str) -> asyncio.Queue[dict[str, Any]]: + q: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=self._max_queue) + with self._lock: + self._subs.setdefault(run_id, set()).add(q) + return q + + def unsubscribe(self, run_id: str, q: asyncio.Queue[dict[str, Any]]) -> None: + with self._lock: + subs = self._subs.get(run_id) + if subs is not None: + subs.discard(q) + if not subs: + self._subs.pop(run_id, None) + + def publish(self, run_id: str, event: dict[str, Any]) -> None: + """Fan ``event`` out to this run's subscribers. Safe from any thread; + a no-op if nobody is watching or the loop isn't bound yet. + """ + loop = self._loop + if loop is None: + return + with self._lock: + if run_id not in self._subs: + return + try: + loop.call_soon_threadsafe(self._deliver, run_id, event) + except RuntimeError: # loop closed during shutdown + pass + + def _deliver(self, run_id: str, event: dict[str, Any]) -> None: + # Runs on the event loop. + with self._lock: + queues = list(self._subs.get(run_id, ())) + for q in queues: + try: + q.put_nowait(event) + except asyncio.QueueFull: + # Slow consumer: drop its oldest frame to make room for the new. + try: + q.get_nowait() + q.put_nowait(event) + except (asyncio.QueueEmpty, asyncio.QueueFull): + pass diff --git a/src/daskgenie/collector/store.py b/src/daskgenie/collector/store.py index 5ba01c5..5fcb732 100644 --- a/src/daskgenie/collector/store.py +++ b/src/daskgenie/collector/store.py @@ -23,9 +23,10 @@ import time import uuid from pathlib import Path -from typing import Any +from typing import Any, Protocol from daskgenie.common.schemas import ( + AllocationSite, ChunkMeta, DeathEvent, GraphUpload, @@ -34,11 +35,58 @@ SampleBatch, ) + +class StoreProtocol(Protocol): + """The storage contract shared by the SQLite :class:`Store` (tests/dev) and + the Postgres/Timescale ``TimescaleStore`` (default in Docker). ``create_app`` + is typed against this so it stays backend-agnostic. + """ + + def create_run(self, name: str = ..., origin: str = ..., origin_ip: str = ...) -> RunInfo: ... + def ensure_run(self, run_id: str) -> None: ... + def list_runs(self) -> list[RunInfo]: ... + def get_run(self, run_id: str) -> RunInfo | None: ... + def delete_run(self, run_id: str) -> bool: ... + def add_samples(self, batch: SampleBatch) -> None: ... + def add_graph(self, upload: GraphUpload) -> None: ... + def add_death(self, event: DeathEvent) -> None: ... + def timeline( + self, run_id: str, worker: str | None = ..., limit: int = ... + ) -> list[dict[str, Any]]: ... + def chunks_for(self, run_id: str, task_key: str) -> list[ChunkMeta]: ... + def sites_for(self, run_id: str, task_key: str) -> list[AllocationSite]: ... + def alloc_sites( + self, + run_id: str, + limit: int = ..., + start: float | None = ..., + end: float | None = ..., + ) -> list[dict[str, Any]]: ... + def flamegraph( + self, + run_id: str, + worker: str | None = ..., + start: float | None = ..., + end: float | None = ..., + limit: int = ..., + ) -> dict[str, Any]: ... + def task_memory(self, run_id: str, limit: int = ...) -> list[dict[str, Any]]: ... + def alloc_timeline(self, run_id: str) -> list[dict[str, Any]]: ... + def worker_status(self, run_id: str) -> list[dict[str, Any]]: ... + def graph(self, run_id: str) -> dict[str, Any]: ... + def deaths(self, run_id: str) -> list[dict[str, Any]]: ... + def spans(self, run_id: str, limit: int = ...) -> list[dict[str, Any]]: ... + def layer_stats(self, run_id: str) -> list[dict[str, Any]]: ... + def latest_memory_by_worker(self) -> dict[str, MemorySample]: ... + + _SCHEMA = """ CREATE TABLE IF NOT EXISTS runs ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - created_at REAL NOT NULL + created_at REAL NOT NULL, + origin TEXT NOT NULL DEFAULT '', + origin_ip TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS samples ( @@ -106,6 +154,7 @@ worker TEXT NOT NULL, suspect_keys TEXT NOT NULL, suspect_chunks TEXT NOT NULL, + suspect_sites TEXT NOT NULL DEFAULT '[]', suspected_oom INTEGER NOT NULL, reason TEXT NOT NULL ); @@ -121,9 +170,81 @@ worker TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_spans_run_start ON task_spans(run_id, start); + +-- One row per (memray epoch, hot source line). ``ts`` is the epoch end; a line +-- appears once per epoch it was live in, so the peak-per-line query takes the +-- MAX(hwm_bytes) across epochs, not a sum (epochs are disjoint time windows). +CREATE TABLE IF NOT EXISTS alloc_sites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + worker TEXT NOT NULL, + ts REAL NOT NULL, + filename TEXT NOT NULL, + lineno INTEGER NOT NULL, + function TEXT NOT NULL, + hwm_bytes INTEGER NOT NULL, + n_allocations INTEGER NOT NULL, + task_key TEXT NOT NULL, + layer TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_alloc_run ON alloc_sites(run_id); +CREATE INDEX IF NOT EXISTS idx_alloc_run_task ON alloc_sites(run_id, task_key); + +CREATE TABLE IF NOT EXISTS task_memory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + key TEXT NOT NULL, + layer TEXT NOT NULL, + worker TEXT NOT NULL, + peak_rss_delta INTEGER NOT NULL, + top_sites TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_taskmem_run ON task_memory(run_id); + +CREATE TABLE IF NOT EXISTS worker_status ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + worker TEXT NOT NULL, + timestamp REAL NOT NULL, + rss_bytes INTEGER NOT NULL, + managed_bytes INTEGER NOT NULL, + memory_limit INTEGER NOT NULL, + cpu REAL NOT NULL, + nthreads INTEGER NOT NULL, + executing INTEGER NOT NULL, + ready INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_wstatus_run_worker_ts ON worker_status(run_id, worker, timestamp); + +-- One row per (epoch, unique call stack): the full root->leaf frames as JSON +-- plus the high-water-mark bytes, for the per-worker flamegraph / tree. +CREATE TABLE IF NOT EXISTS alloc_stacks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + worker TEXT NOT NULL, + ts REAL NOT NULL, + frames TEXT NOT NULL, + hwm_bytes INTEGER NOT NULL, + n_allocations INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_stacks_run_worker ON alloc_stacks(run_id, worker); """ +def make_store(dsn: str | None = None, db: str | Path = "daskgenie.db") -> StoreProtocol: + """Pick the collector backend. A Postgres/Timescale ``dsn`` (from + ``DASKGENIE_DSN``) selects :class:`TimescaleStore` — the default in Docker; + otherwise the self-contained SQLite :class:`Store`, which is also what the + test suite uses (``:memory:``). psycopg is imported lazily so SQLite-only + installs don't need it. + """ + if dsn: + from daskgenie.collector.store_tsdb import TimescaleStore + + return TimescaleStore(dsn) + return Store(db) + + class Store: def __init__(self, path: str | Path = "daskgenie.db") -> None: # ":memory:" is honoured for tests; a path persists across restarts. @@ -132,6 +253,14 @@ def __init__(self, path: str | Path = "daskgenie.db") -> None: self._lock = threading.Lock() with self._lock: self._conn.executescript(_SCHEMA) + # Migrate DBs created before origin tracking existed. + for col in ("origin", "origin_ip"): + try: + self._conn.execute( + f"ALTER TABLE runs ADD COLUMN {col} TEXT NOT NULL DEFAULT ''" + ) + except sqlite3.OperationalError: + pass # column already exists self._conn.commit() def close(self) -> None: @@ -140,17 +269,17 @@ def close(self) -> None: # -- runs --------------------------------------------------------------- - def create_run(self, name: str = "") -> RunInfo: + def create_run(self, name: str = "", origin: str = "", origin_ip: str = "") -> RunInfo: run_id = uuid.uuid4().hex[:12] created = time.time() name = name or f"run-{run_id}" with self._lock: self._conn.execute( - "INSERT INTO runs (id, name, created_at) VALUES (?, ?, ?)", - (run_id, name, created), + "INSERT INTO runs (id, name, created_at, origin, origin_ip) VALUES (?, ?, ?, ?, ?)", + (run_id, name, created, origin, origin_ip), ) self._conn.commit() - return RunInfo(id=run_id, name=name, created_at=created) + return RunInfo(id=run_id, name=name, created_at=created, origin=origin, origin_ip=origin_ip) def ensure_run(self, run_id: str) -> None: """Create a placeholder run row if data arrives for an unknown run_id. @@ -167,7 +296,7 @@ def ensure_run(self, run_id: str) -> None: def list_runs(self) -> list[RunInfo]: with self._lock: rows = self._conn.execute( - "SELECT id, name, created_at FROM runs ORDER BY created_at DESC" + "SELECT id, name, created_at, origin, origin_ip FROM runs ORDER BY created_at DESC" ).fetchall() runs = [] for r in rows: @@ -181,14 +310,22 @@ def list_runs(self) -> list[RunInfo]: ), } runs.append( - RunInfo(id=r["id"], name=r["name"], created_at=r["created_at"], counts=counts) + RunInfo( + id=r["id"], + name=r["name"], + created_at=r["created_at"], + origin=r["origin"], + origin_ip=r["origin_ip"], + counts=counts, + ) ) return runs def get_run(self, run_id: str) -> RunInfo | None: with self._lock: r = self._conn.execute( - "SELECT id, name, created_at FROM runs WHERE id = ?", (run_id,) + "SELECT id, name, created_at, origin, origin_ip FROM runs WHERE id = ?", + (run_id,), ).fetchone() if r is None: return None @@ -199,7 +336,14 @@ def get_run(self, run_id: str) -> RunInfo | None: "SELECT COUNT(DISTINCT worker) FROM samples WHERE run_id = ?", run_id ), } - return RunInfo(id=r["id"], name=r["name"], created_at=r["created_at"], counts=counts) + return RunInfo( + id=r["id"], + name=r["name"], + created_at=r["created_at"], + origin=r["origin"], + origin_ip=r["origin_ip"], + counts=counts, + ) def delete_run(self, run_id: str) -> bool: with self._lock: @@ -214,6 +358,10 @@ def delete_run(self, run_id: str) -> bool: "graph_meta", "deaths", "task_spans", + "alloc_sites", + "alloc_stacks", + "task_memory", + "worker_status", ): self._conn.execute(f"DELETE FROM {table} WHERE run_id = ?", (run_id,)) # noqa: S608 self._conn.commit() @@ -253,9 +401,77 @@ def add_samples(self, batch: SampleBatch) -> None: self._conn.executemany( "INSERT INTO task_spans (run_id, key, layer, start, end, worker) " "VALUES (?, ?, ?, ?, ?, ?)", + [(batch.run_id, s.key, s.layer, s.start, s.end, s.worker) for s in batch.spans], + ) + self._conn.executemany( + "INSERT INTO alloc_sites (run_id, worker, ts, filename, lineno, function, " + "hwm_bytes, n_allocations, task_key, layer) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + ( + batch.run_id, + ep.worker, + ep.end, + site.filename, + site.lineno, + site.function, + site.hwm_bytes, + site.n_allocations, + site.task_key, + site.layer, + ) + for ep in batch.epochs + for site in ep.sites + ], + ) + self._conn.executemany( + "INSERT INTO alloc_stacks (run_id, worker, ts, frames, hwm_bytes, n_allocations) " + "VALUES (?, ?, ?, ?, ?, ?)", + [ + ( + batch.run_id, + ep.worker, + ep.end, + json.dumps([[f.function, f.filename, f.lineno] for f in st.frames]), + st.hwm_bytes, + st.n_allocations, + ) + for ep in batch.epochs + for st in ep.stacks + ], + ) + self._conn.executemany( + "INSERT INTO task_memory (run_id, key, layer, worker, peak_rss_delta, top_sites) " + "VALUES (?, ?, ?, ?, ?, ?)", + [ + ( + batch.run_id, + tm.key, + tm.layer, + tm.worker, + tm.peak_rss_delta, + json.dumps([s.model_dump() for s in tm.top_sites]), + ) + for tm in batch.task_memory + ], + ) + self._conn.executemany( + "INSERT INTO worker_status (run_id, worker, timestamp, rss_bytes, managed_bytes, " + "memory_limit, cpu, nthreads, executing, ready) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ - (batch.run_id, s.key, s.layer, s.start, s.end, s.worker) - for s in batch.spans + ( + batch.run_id, + st.worker, + st.timestamp, + st.rss_bytes, + st.managed_bytes, + st.memory_limit, + st.cpu, + st.nthreads, + st.executing, + st.ready, + ) + for st in batch.statuses ], ) self._conn.commit() @@ -305,13 +521,14 @@ def add_death(self, event: DeathEvent) -> None: with self._lock: self._conn.execute( "INSERT INTO deaths (run_id, timestamp, worker, suspect_keys, suspect_chunks, " - "suspected_oom, reason) VALUES (?, ?, ?, ?, ?, ?, ?)", + "suspect_sites, suspected_oom, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( event.run_id, event.timestamp, event.worker, json.dumps(event.suspect_keys), json.dumps([c.model_dump() for c in event.suspect_chunks]), + json.dumps([s.model_dump() for s in event.suspect_sites]), int(event.suspected_oom), event.reason, ), @@ -365,6 +582,172 @@ def chunks_for(self, run_id: str, task_key: str) -> list[ChunkMeta]: for r in rows ] + def sites_for(self, run_id: str, task_key: str) -> list[AllocationSite]: + """Deep allocation lines recorded for a task — the peak (MAX) per line + across the epochs that overlapped it. Feeds the death-site join. + """ + with self._lock: + rows = self._conn.execute( + "SELECT filename, lineno, function, MAX(hwm_bytes) AS hwm, " + "SUM(n_allocations) AS na, task_key, layer FROM alloc_sites " + "WHERE run_id = ? AND task_key = ? " + "GROUP BY filename, lineno, function ORDER BY hwm DESC", + (run_id, task_key), + ).fetchall() + return [ + AllocationSite( + filename=r["filename"], + lineno=r["lineno"], + function=r["function"], + hwm_bytes=r["hwm"], + n_allocations=r["na"] or 0, + task_key=r["task_key"], + layer=r["layer"], + ) + for r in rows + ] + + def alloc_sites( + self, + run_id: str, + limit: int = 500, + start: float | None = None, + end: float | None = None, + ) -> list[dict[str, Any]]: + """Per-source-line deep memory, peak bytes descending — the headline of + the deep Memory view. Peak = MAX(hwm_bytes) across epochs (disjoint + windows), so a line held across time isn't double-counted. A ``start``/ + ``end`` window scopes it to a moment (what was allocating at a spike). + """ + clauses = ["run_id = ?"] + params: list[Any] = [run_id] + if start is not None: + clauses.append("ts >= ?") + params.append(start) + if end is not None: + clauses.append("ts <= ?") + params.append(end) + where = " AND ".join(clauses) + with self._lock: + rows = self._conn.execute( + "SELECT filename, lineno, function, MAX(hwm_bytes) AS hwm, " # noqa: S608 + "SUM(n_allocations) AS na, " + f"GROUP_CONCAT(DISTINCT layer) AS layers FROM alloc_sites WHERE {where} " + "GROUP BY filename, lineno, function ORDER BY hwm DESC LIMIT ?", + (*params, limit), + ).fetchall() + return [ + { + "filename": r["filename"], + "lineno": r["lineno"], + "function": r["function"], + "hwm_bytes": r["hwm"], + "n_allocations": r["na"] or 0, + "layers": [x for x in (r["layers"] or "").split(",") if x], + } + for r in rows + ] + + def flamegraph( + self, + run_id: str, + worker: str | None = None, + start: float | None = None, + end: float | None = None, + limit: int = 400, + ) -> dict[str, Any]: + """Per-unique-call-stack peak bytes for the flamegraph. Peak = MAX across + epochs (disjoint windows) of the same stack. Optionally scoped to one + worker and/or a time window (the "over time" selector). + """ + clauses = ["run_id = ?"] + params: list[Any] = [run_id] + if worker: + clauses.append("worker = ?") + params.append(worker) + if start is not None: + clauses.append("ts >= ?") + params.append(start) + if end is not None: + clauses.append("ts <= ?") + params.append(end) + where = " AND ".join(clauses) + with self._lock: + workers = [ + r[0] + for r in self._conn.execute( + "SELECT DISTINCT worker FROM alloc_stacks WHERE run_id = ? ORDER BY worker", + (run_id,), + ).fetchall() + ] + rows = self._conn.execute( + f"SELECT frames, MAX(hwm_bytes) AS hwm, SUM(n_allocations) AS na " # noqa: S608 + f"FROM alloc_stacks WHERE {where} GROUP BY frames ORDER BY hwm DESC LIMIT ?", + (*params, limit), + ).fetchall() + stacks = [ + { + "frames": [ + {"function": f[0], "filename": f[1], "lineno": f[2]} + for f in json.loads(r["frames"]) + ], + "hwm_bytes": r["hwm"], + "n_allocations": r["na"] or 0, + } + for r in rows + ] + return {"workers": workers, "stacks": stacks} + + def alloc_timeline(self, run_id: str) -> list[dict[str, Any]]: + """Per-(epoch, layer) high-water-mark bytes — a memory-over-time series + grouped by task layer, so you can see which layer's memory grows when. + Rows with no layer attribution are bucketed as ``(unattributed)``. + """ + with self._lock: + rows = self._conn.execute( + "SELECT ts, layer, SUM(hwm_bytes) AS bytes FROM alloc_sites " + "WHERE run_id = ? GROUP BY ts, layer ORDER BY ts", + (run_id,), + ).fetchall() + return [ + {"ts": r["ts"], "layer": r["layer"] or "(unattributed)", "bytes": r["bytes"]} + for r in rows + ] + + def task_memory(self, run_id: str, limit: int = 2000) -> list[dict[str, Any]]: + """Per-task peak RSS delta + dominant allocation lines, largest first.""" + with self._lock: + rows = self._conn.execute( + "SELECT key, layer, worker, MAX(peak_rss_delta) AS peak, top_sites " + "FROM task_memory WHERE run_id = ? GROUP BY key " + "ORDER BY peak DESC LIMIT ?", + (run_id, limit), + ).fetchall() + return [ + { + "key": r["key"], + "layer": r["layer"], + "worker": r["worker"], + "peak_rss_delta": r["peak"], + "top_sites": json.loads(r["top_sites"]), + } + for r in rows + ] + + def worker_status(self, run_id: str) -> list[dict[str, Any]]: + """The most recent heartbeat per worker — the live Workers table.""" + with self._lock: + rows = self._conn.execute( + "SELECT s.worker, s.timestamp, s.rss_bytes, s.managed_bytes, s.memory_limit, " + "s.cpu, s.nthreads, s.executing, s.ready FROM worker_status s " + "JOIN (SELECT worker, MAX(timestamp) AS mt FROM worker_status " + "WHERE run_id = ? GROUP BY worker) m " + "ON s.worker = m.worker AND s.timestamp = m.mt WHERE s.run_id = ? " + "ORDER BY s.worker", + (run_id, run_id), + ).fetchall() + return [dict(r) for r in rows] + def graph(self, run_id: str) -> dict[str, Any]: with self._lock: layers = self._conn.execute( @@ -400,8 +783,8 @@ def graph(self, run_id: str) -> dict[str, Any]: def deaths(self, run_id: str) -> list[dict[str, Any]]: with self._lock: rows = self._conn.execute( - "SELECT timestamp, worker, suspect_keys, suspect_chunks, suspected_oom, " - "reason FROM deaths WHERE run_id = ? ORDER BY timestamp DESC", + "SELECT timestamp, worker, suspect_keys, suspect_chunks, suspect_sites, " + "suspected_oom, reason FROM deaths WHERE run_id = ? ORDER BY timestamp DESC", (run_id,), ).fetchall() return [ @@ -410,6 +793,7 @@ def deaths(self, run_id: str) -> list[dict[str, Any]]: "worker": r["worker"], "suspect_keys": json.loads(r["suspect_keys"]), "suspect_chunks": json.loads(r["suspect_chunks"]), + "suspect_sites": json.loads(r["suspect_sites"]), "suspected_oom": bool(r["suspected_oom"]), "reason": r["reason"], } diff --git a/src/daskgenie/collector/store_tsdb.py b/src/daskgenie/collector/store_tsdb.py new file mode 100644 index 0000000..0ce6bf2 --- /dev/null +++ b/src/daskgenie/collector/store_tsdb.py @@ -0,0 +1,781 @@ +"""TimescaleDB-backed event store — the default collector backend. + +Mirrors the public surface of :class:`daskgenie.collector.store.Store` (the +SQLite backend kept for tests/dev) so the FastAPI app is storage-agnostic: +``create_app`` takes whichever one ``make_store`` selected. Timescale is a +Postgres extension, so this is plain ``psycopg`` with three hypertables on the +high-rate time-series tables (samples / task_spans / alloc_sites / +worker_status); everything else is ordinary Postgres. + +Concurrency mirrors the SQLite store: one connection guarded by a lock. At the +collector's ingest rate that is plenty; the upgrade path is a psycopg pool +behind this same API. +""" + +from __future__ import annotations + +import json +import threading +import time +import uuid +from typing import Any + +import psycopg + +from daskgenie.common.schemas import ( + AllocationSite, + ChunkMeta, + DeathEvent, + GraphUpload, + MemorySample, + RunInfo, + SampleBatch, +) + +_SCHEMA = """ +CREATE EXTENSION IF NOT EXISTS timescaledb; + +CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at DOUBLE PRECISION NOT NULL, + origin TEXT NOT NULL DEFAULT '', + origin_ip TEXT NOT NULL DEFAULT '' +); +ALTER TABLE runs ADD COLUMN IF NOT EXISTS origin TEXT NOT NULL DEFAULT ''; +ALTER TABLE runs ADD COLUMN IF NOT EXISTS origin_ip TEXT NOT NULL DEFAULT ''; + +CREATE TABLE IF NOT EXISTS samples ( + id BIGSERIAL, + run_id TEXT NOT NULL, + worker TEXT NOT NULL, + timestamp DOUBLE PRECISION NOT NULL, + rss_bytes BIGINT NOT NULL, + managed_bytes BIGINT NOT NULL, + executing_keys TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_samples_run_worker_ts ON samples(run_id, worker, timestamp); + +CREATE TABLE IF NOT EXISTS chunks ( + id BIGSERIAL, + run_id TEXT NOT NULL, + task_key TEXT NOT NULL, + shape TEXT NOT NULL, + dtype TEXT NOT NULL, + nbytes BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_chunks_run_task_key ON chunks(run_id, task_key); + +CREATE TABLE IF NOT EXISTS graph_layers ( + run_id TEXT NOT NULL, + layer TEXT NOT NULL, + filename TEXT NOT NULL, + lineno INTEGER NOT NULL, + code_snippet TEXT NOT NULL, + PRIMARY KEY (run_id, layer) +); + +CREATE TABLE IF NOT EXISTS graph_deps ( + run_id TEXT NOT NULL, + layer TEXT NOT NULL, + dep TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS graph_nodes ( + run_id TEXT NOT NULL, + key TEXT NOT NULL, + layer TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_gnodes_run ON graph_nodes(run_id); + +CREATE TABLE IF NOT EXISTS graph_edges ( + run_id TEXT NOT NULL, + src TEXT NOT NULL, + dst TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_gedges_run ON graph_edges(run_id); + +CREATE TABLE IF NOT EXISTS graph_meta ( + run_id TEXT PRIMARY KEY, + task_count INTEGER NOT NULL, + truncated INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS deaths ( + id BIGSERIAL PRIMARY KEY, + run_id TEXT NOT NULL, + timestamp DOUBLE PRECISION NOT NULL, + worker TEXT NOT NULL, + suspect_keys TEXT NOT NULL, + suspect_chunks TEXT NOT NULL, + suspect_sites TEXT NOT NULL, + suspected_oom INTEGER NOT NULL, + reason TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_deaths_run ON deaths(run_id, timestamp); + +CREATE TABLE IF NOT EXISTS task_spans ( + id BIGSERIAL, + run_id TEXT NOT NULL, + key TEXT NOT NULL, + layer TEXT NOT NULL, + start DOUBLE PRECISION NOT NULL, + "end" DOUBLE PRECISION NOT NULL, + worker TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_spans_run_start ON task_spans(run_id, start); + +CREATE TABLE IF NOT EXISTS alloc_sites ( + id BIGSERIAL, + run_id TEXT NOT NULL, + worker TEXT NOT NULL, + ts DOUBLE PRECISION NOT NULL, + filename TEXT NOT NULL, + lineno INTEGER NOT NULL, + function TEXT NOT NULL, + hwm_bytes BIGINT NOT NULL, + n_allocations BIGINT NOT NULL, + task_key TEXT NOT NULL, + layer TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_alloc_run ON alloc_sites(run_id); +CREATE INDEX IF NOT EXISTS idx_alloc_run_task ON alloc_sites(run_id, task_key); + +CREATE TABLE IF NOT EXISTS task_memory ( + id BIGSERIAL, + run_id TEXT NOT NULL, + key TEXT NOT NULL, + layer TEXT NOT NULL, + worker TEXT NOT NULL, + peak_rss_delta BIGINT NOT NULL, + top_sites TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_taskmem_run ON task_memory(run_id); + +CREATE TABLE IF NOT EXISTS worker_status ( + id BIGSERIAL, + run_id TEXT NOT NULL, + worker TEXT NOT NULL, + timestamp DOUBLE PRECISION NOT NULL, + rss_bytes BIGINT NOT NULL, + managed_bytes BIGINT NOT NULL, + memory_limit BIGINT NOT NULL, + cpu DOUBLE PRECISION NOT NULL, + nthreads INTEGER NOT NULL, + executing INTEGER NOT NULL, + ready INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_wstatus_run_worker_ts ON worker_status(run_id, worker, timestamp); + +CREATE TABLE IF NOT EXISTS alloc_stacks ( + id BIGSERIAL, + run_id TEXT NOT NULL, + worker TEXT NOT NULL, + ts DOUBLE PRECISION NOT NULL, + frames TEXT NOT NULL, + hwm_bytes BIGINT NOT NULL, + n_allocations BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_stacks_run_worker ON alloc_stacks(run_id, worker); +""" + +# Turn the high-rate tables into hypertables. Wrapped individually because +# create_hypertable errors if the table already has rows and isn't yet a +# hypertable; if_not_exists + migrate_data keeps re-runs idempotent. +_HYPERTABLES = [ + ("samples", "timestamp"), + ("task_spans", "start"), + ("alloc_sites", "ts"), + ("alloc_stacks", "ts"), + ("worker_status", "timestamp"), +] + + +def _split_statements(script: str) -> list[str]: + """Split a simple DDL script into individual statements. The schema has no + semicolons inside statements (no functions/dollar-quoting), so splitting on + ';' is safe.""" + return [s.strip() for s in script.split(";") if s.strip()] + + +class TimescaleStore: + def __init__(self, dsn: str) -> None: + # autocommit so a single failing statement can't poison the shared + # connection for every later request; the multi-insert writes below wrap + # their statements in an explicit transaction for atomicity. + self._conn = psycopg.connect(dsn, autocommit=True) + self._lock = threading.Lock() + with self._lock: + # psycopg3's execute() runs a single command, so the schema (a + # multi-statement script) must be applied statement by statement. + for statement in _split_statements(_SCHEMA): + self._conn.execute(statement) + for table, col in _HYPERTABLES: + try: + self._conn.execute( + "SELECT create_hypertable(%s, %s, if_not_exists => TRUE, " + "migrate_data => TRUE)", + (table, col), + ) + except psycopg.Error: + pass + + def close(self) -> None: + with self._lock: + self._conn.close() + + # -- runs --------------------------------------------------------------- + + def create_run(self, name: str = "", origin: str = "", origin_ip: str = "") -> RunInfo: + run_id = uuid.uuid4().hex[:12] + created = time.time() + name = name or f"run-{run_id}" + with self._lock: + self._conn.execute( + "INSERT INTO runs (id, name, created_at, origin, origin_ip) " + "VALUES (%s, %s, %s, %s, %s)", + (run_id, name, created, origin, origin_ip), + ) + self._conn.commit() + return RunInfo(id=run_id, name=name, created_at=created, origin=origin, origin_ip=origin_ip) + + def ensure_run(self, run_id: str) -> None: + with self._lock: + self._conn.execute( + "INSERT INTO runs (id, name, created_at) VALUES (%s, %s, %s) " + "ON CONFLICT (id) DO NOTHING", + (run_id, f"run-{run_id}", time.time()), + ) + self._conn.commit() + + def list_runs(self) -> list[RunInfo]: + with self._lock: + rows = self._conn.execute( + "SELECT id, name, created_at, origin, origin_ip FROM runs ORDER BY created_at DESC" + ).fetchall() + runs = [] + for r in rows: + runs.append( + RunInfo( + id=r[0], + name=r[1], + created_at=r[2], + origin=r[3], + origin_ip=r[4], + counts=self._counts(r[0]), + ) + ) + return runs + + def get_run(self, run_id: str) -> RunInfo | None: + with self._lock: + r = self._conn.execute( + "SELECT id, name, created_at, origin, origin_ip FROM runs WHERE id = %s", + (run_id,), + ).fetchone() + if r is None: + return None + counts = self._counts(run_id) + return RunInfo( + id=r[0], name=r[1], created_at=r[2], origin=r[3], origin_ip=r[4], counts=counts + ) + + def _counts(self, run_id: str) -> dict[str, int]: + # caller holds the lock + return { + "samples": self._scalar("SELECT COUNT(*) FROM samples WHERE run_id = %s", run_id), + "deaths": self._scalar("SELECT COUNT(*) FROM deaths WHERE run_id = %s", run_id), + "workers": self._scalar( + "SELECT COUNT(DISTINCT worker) FROM samples WHERE run_id = %s", run_id + ), + } + + def delete_run(self, run_id: str) -> bool: + with self._lock: + cur = self._conn.execute("DELETE FROM runs WHERE id = %s", (run_id,)) + for table in ( + "samples", + "chunks", + "graph_layers", + "graph_deps", + "graph_nodes", + "graph_edges", + "graph_meta", + "deaths", + "task_spans", + "alloc_sites", + "alloc_stacks", + "task_memory", + "worker_status", + ): + self._conn.execute(f"DELETE FROM {table} WHERE run_id = %s", (run_id,)) # noqa: S608 + self._conn.commit() + return (cur.rowcount or 0) > 0 + + def _scalar(self, sql: str, *params: object) -> int: + row = self._conn.execute(sql, params).fetchone() + return int(row[0]) if row else 0 + + # -- ingest ------------------------------------------------------------- + + def add_samples(self, batch: SampleBatch) -> None: + with self._lock, self._conn.cursor() as cur: + cur.executemany( + "INSERT INTO samples (run_id, worker, timestamp, rss_bytes, managed_bytes, " + "executing_keys) VALUES (%s, %s, %s, %s, %s, %s)", + [ + ( + batch.run_id, + batch.worker, + s.timestamp, + s.rss_bytes, + s.managed_bytes, + json.dumps(s.executing_keys), + ) + for s in batch.samples + ], + ) + cur.executemany( + "INSERT INTO chunks (run_id, task_key, shape, dtype, nbytes) " + "VALUES (%s, %s, %s, %s, %s)", + [ + (batch.run_id, c.task_key, json.dumps(list(c.shape)), c.dtype, c.nbytes) + for c in batch.chunks + ], + ) + cur.executemany( + 'INSERT INTO task_spans (run_id, key, layer, start, "end", worker) ' + "VALUES (%s, %s, %s, %s, %s, %s)", + [(batch.run_id, s.key, s.layer, s.start, s.end, s.worker) for s in batch.spans], + ) + cur.executemany( + "INSERT INTO alloc_sites (run_id, worker, ts, filename, lineno, function, " + "hwm_bytes, n_allocations, task_key, layer) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)", + [ + ( + batch.run_id, + ep.worker, + ep.end, + site.filename, + site.lineno, + site.function, + site.hwm_bytes, + site.n_allocations, + site.task_key, + site.layer, + ) + for ep in batch.epochs + for site in ep.sites + ], + ) + cur.executemany( + "INSERT INTO alloc_stacks (run_id, worker, ts, frames, hwm_bytes, n_allocations) " + "VALUES (%s, %s, %s, %s, %s, %s)", + [ + ( + batch.run_id, + ep.worker, + ep.end, + json.dumps([[f.function, f.filename, f.lineno] for f in st.frames]), + st.hwm_bytes, + st.n_allocations, + ) + for ep in batch.epochs + for st in ep.stacks + ], + ) + cur.executemany( + "INSERT INTO task_memory (run_id, key, layer, worker, peak_rss_delta, top_sites) " + "VALUES (%s, %s, %s, %s, %s, %s)", + [ + ( + batch.run_id, + tm.key, + tm.layer, + tm.worker, + tm.peak_rss_delta, + json.dumps([s.model_dump() for s in tm.top_sites]), + ) + for tm in batch.task_memory + ], + ) + cur.executemany( + "INSERT INTO worker_status (run_id, worker, timestamp, rss_bytes, managed_bytes, " + "memory_limit, cpu, nthreads, executing, ready) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)", + [ + ( + batch.run_id, + st.worker, + st.timestamp, + st.rss_bytes, + st.managed_bytes, + st.memory_limit, + st.cpu, + st.nthreads, + st.executing, + st.ready, + ) + for st in batch.statuses + ], + ) + self._conn.commit() + + def add_graph(self, upload: GraphUpload) -> None: + with self._lock, self._conn.cursor() as cur: + cur.executemany( + "INSERT INTO graph_layers (run_id, layer, filename, lineno, code_snippet) " + "VALUES (%s, %s, %s, %s, %s) ON CONFLICT(run_id, layer) DO UPDATE SET " + "filename=excluded.filename, lineno=excluded.lineno, " + "code_snippet=excluded.code_snippet", + [ + (upload.run_id, ly.layer, ly.filename, ly.lineno, ly.code_snippet) + for ly in upload.layers + ], + ) + cur.execute("DELETE FROM graph_deps WHERE run_id = %s", (upload.run_id,)) + cur.executemany( + "INSERT INTO graph_deps (run_id, layer, dep) VALUES (%s, %s, %s)", + [ + (upload.run_id, layer, dep) + for layer, deps in upload.layer_dependencies.items() + for dep in deps + ], + ) + if upload.nodes or upload.edges or upload.task_count: + cur.execute("DELETE FROM graph_nodes WHERE run_id = %s", (upload.run_id,)) + cur.execute("DELETE FROM graph_edges WHERE run_id = %s", (upload.run_id,)) + cur.executemany( + "INSERT INTO graph_nodes (run_id, key, layer) VALUES (%s, %s, %s)", + [(upload.run_id, n.key, n.layer) for n in upload.nodes], + ) + cur.executemany( + "INSERT INTO graph_edges (run_id, src, dst) VALUES (%s, %s, %s)", + [(upload.run_id, src, dst) for src, dst in upload.edges], + ) + cur.execute( + "INSERT INTO graph_meta (run_id, task_count, truncated) VALUES (%s, %s, %s) " + "ON CONFLICT(run_id) DO UPDATE SET task_count=excluded.task_count, " + "truncated=excluded.truncated", + (upload.run_id, upload.task_count, int(upload.truncated)), + ) + self._conn.commit() + + def add_death(self, event: DeathEvent) -> None: + with self._lock: + self._conn.execute( + "INSERT INTO deaths (run_id, timestamp, worker, suspect_keys, suspect_chunks, " + "suspect_sites, suspected_oom, reason) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + ( + event.run_id, + event.timestamp, + event.worker, + json.dumps(event.suspect_keys), + json.dumps([c.model_dump() for c in event.suspect_chunks]), + json.dumps([s.model_dump() for s in event.suspect_sites]), + int(event.suspected_oom), + event.reason, + ), + ) + self._conn.commit() + + # -- query (all scoped to a run) --------------------------------------- + + def timeline( + self, run_id: str, worker: str | None = None, limit: int = 10000 + ) -> list[dict[str, Any]]: + with self._lock: + if worker is None: + rows = self._conn.execute( + "SELECT worker, timestamp, rss_bytes, managed_bytes, executing_keys " + "FROM samples WHERE run_id = %s ORDER BY timestamp DESC LIMIT %s", + (run_id, limit), + ).fetchall() + else: + rows = self._conn.execute( + "SELECT worker, timestamp, rss_bytes, managed_bytes, executing_keys " + "FROM samples WHERE run_id = %s AND worker = %s " + "ORDER BY timestamp DESC LIMIT %s", + (run_id, worker, limit), + ).fetchall() + return [ + { + "worker": r[0], + "timestamp": r[1], + "rss_bytes": r[2], + "managed_bytes": r[3], + "executing_keys": json.loads(r[4]), + } + for r in rows + ] + + def chunks_for(self, run_id: str, task_key: str) -> list[ChunkMeta]: + with self._lock: + rows = self._conn.execute( + "SELECT task_key, shape, dtype, nbytes FROM chunks " + "WHERE run_id = %s AND task_key = %s ORDER BY nbytes DESC", + (run_id, task_key), + ).fetchall() + return [ + ChunkMeta(task_key=r[0], shape=tuple(json.loads(r[1])), dtype=r[2], nbytes=r[3]) + for r in rows + ] + + def sites_for(self, run_id: str, task_key: str) -> list[AllocationSite]: + with self._lock: + rows = self._conn.execute( + "SELECT filename, lineno, function, MAX(hwm_bytes) AS hwm, " + "SUM(n_allocations) AS na, MAX(task_key) AS tk, MAX(layer) AS ly " + "FROM alloc_sites WHERE run_id = %s AND task_key = %s " + "GROUP BY filename, lineno, function ORDER BY hwm DESC", + (run_id, task_key), + ).fetchall() + return [ + AllocationSite( + filename=r[0], + lineno=r[1], + function=r[2], + hwm_bytes=int(r[3] or 0), + n_allocations=int(r[4] or 0), + task_key=r[5], + layer=r[6], + ) + for r in rows + ] + + def alloc_sites( + self, + run_id: str, + limit: int = 500, + start: float | None = None, + end: float | None = None, + ) -> list[dict[str, Any]]: + clauses = ["run_id = %s"] + params: list[Any] = [run_id] + if start is not None: + clauses.append("ts >= %s") + params.append(start) + if end is not None: + clauses.append("ts <= %s") + params.append(end) + where = " AND ".join(clauses) + with self._lock: + rows = self._conn.execute( + "SELECT filename, lineno, function, MAX(hwm_bytes) AS hwm, " # noqa: S608 + "SUM(n_allocations) AS na, string_agg(DISTINCT layer, ',') AS layers " + f"FROM alloc_sites WHERE {where} GROUP BY filename, lineno, function " + "ORDER BY hwm DESC LIMIT %s", + (*params, limit), + ).fetchall() + return [ + { + "filename": r[0], + "lineno": r[1], + "function": r[2], + "hwm_bytes": int(r[3] or 0), + "n_allocations": int(r[4] or 0), + "layers": [x for x in (r[5] or "").split(",") if x], + } + for r in rows + ] + + def flamegraph( + self, + run_id: str, + worker: str | None = None, + start: float | None = None, + end: float | None = None, + limit: int = 400, + ) -> dict[str, Any]: + clauses = ["run_id = %s"] + params: list[Any] = [run_id] + if worker: + clauses.append("worker = %s") + params.append(worker) + if start is not None: + clauses.append("ts >= %s") + params.append(start) + if end is not None: + clauses.append("ts <= %s") + params.append(end) + where = " AND ".join(clauses) + with self._lock: + workers = [ + r[0] + for r in self._conn.execute( + "SELECT DISTINCT worker FROM alloc_stacks WHERE run_id = %s ORDER BY worker", + (run_id,), + ).fetchall() + ] + rows = self._conn.execute( + f"SELECT frames, MAX(hwm_bytes) AS hwm, SUM(n_allocations) AS na " # noqa: S608 + f"FROM alloc_stacks WHERE {where} GROUP BY frames ORDER BY hwm DESC LIMIT %s", + (*params, limit), + ).fetchall() + stacks = [ + { + "frames": [ + {"function": f[0], "filename": f[1], "lineno": f[2]} for f in json.loads(r[0]) + ], + "hwm_bytes": int(r[1] or 0), + "n_allocations": int(r[2] or 0), + } + for r in rows + ] + return {"workers": workers, "stacks": stacks} + + def alloc_timeline(self, run_id: str) -> list[dict[str, Any]]: + with self._lock: + rows = self._conn.execute( + "SELECT ts, layer, SUM(hwm_bytes) AS bytes FROM alloc_sites " + "WHERE run_id = %s GROUP BY ts, layer ORDER BY ts", + (run_id,), + ).fetchall() + # Postgres SUM(bigint) is numeric -> psycopg Decimal -> JSON string; cast + # to int so the dashboard gets real numbers (not string-concatenated ones). + return [ + {"ts": r[0], "layer": r[1] or "(unattributed)", "bytes": int(r[2] or 0)} for r in rows + ] + + def task_memory(self, run_id: str, limit: int = 2000) -> list[dict[str, Any]]: + with self._lock: + rows = self._conn.execute( + "SELECT key, MAX(layer) AS layer, MAX(worker) AS worker, " + "MAX(peak_rss_delta) AS peak, " + "(array_agg(top_sites ORDER BY peak_rss_delta DESC))[1] AS top " + "FROM task_memory WHERE run_id = %s GROUP BY key " + "ORDER BY peak DESC LIMIT %s", + (run_id, limit), + ).fetchall() + return [ + { + "key": r[0], + "layer": r[1], + "worker": r[2], + "peak_rss_delta": int(r[3] or 0), + "top_sites": json.loads(r[4]) if r[4] else [], + } + for r in rows + ] + + def worker_status(self, run_id: str) -> list[dict[str, Any]]: + with self._lock: + rows = self._conn.execute( + "SELECT DISTINCT ON (worker) worker, timestamp, rss_bytes, managed_bytes, " + "memory_limit, cpu, nthreads, executing, ready FROM worker_status " + "WHERE run_id = %s ORDER BY worker, timestamp DESC", + (run_id,), + ).fetchall() + cols = [ + "worker", + "timestamp", + "rss_bytes", + "managed_bytes", + "memory_limit", + "cpu", + "nthreads", + "executing", + "ready", + ] + return [dict(zip(cols, r, strict=True)) for r in rows] + + def graph(self, run_id: str) -> dict[str, Any]: + with self._lock: + layers = self._conn.execute( + "SELECT layer, filename, lineno, code_snippet FROM graph_layers WHERE run_id = %s", + (run_id,), + ).fetchall() + deps = self._conn.execute( + "SELECT layer, dep FROM graph_deps WHERE run_id = %s", (run_id,) + ).fetchall() + gnodes = self._conn.execute( + "SELECT key, layer FROM graph_nodes WHERE run_id = %s", (run_id,) + ).fetchall() + gedges = self._conn.execute( + "SELECT src, dst FROM graph_edges WHERE run_id = %s", (run_id,) + ).fetchall() + meta = self._conn.execute( + "SELECT task_count, truncated FROM graph_meta WHERE run_id = %s", (run_id,) + ).fetchone() + dep_map: dict[str, list[str]] = {} + for d in deps: + dep_map.setdefault(d[0], []).append(d[1]) + return { + "run_id": run_id, + "layers": [ + {"layer": r[0], "filename": r[1], "lineno": r[2], "code_snippet": r[3]} + for r in layers + ], + "layer_dependencies": dep_map, + "nodes": [{"key": n[0], "layer": n[1]} for n in gnodes], + "edges": [[e[0], e[1]] for e in gedges], + "task_count": meta[0] if meta else 0, + "truncated": bool(meta[1]) if meta else False, + } + + def deaths(self, run_id: str) -> list[dict[str, Any]]: + with self._lock: + rows = self._conn.execute( + "SELECT timestamp, worker, suspect_keys, suspect_chunks, suspect_sites, " + "suspected_oom, reason FROM deaths WHERE run_id = %s ORDER BY timestamp DESC", + (run_id,), + ).fetchall() + return [ + { + "timestamp": r[0], + "worker": r[1], + "suspect_keys": json.loads(r[2]), + "suspect_chunks": json.loads(r[3]), + "suspect_sites": json.loads(r[4]), + "suspected_oom": bool(r[5]), + "reason": r[6], + } + for r in rows + ] + + def spans(self, run_id: str, limit: int = 20000) -> list[dict[str, Any]]: + with self._lock: + rows = self._conn.execute( + 'SELECT key, layer, start, "end", worker FROM task_spans ' + "WHERE run_id = %s ORDER BY start LIMIT %s", + (run_id, limit), + ).fetchall() + return [ + {"key": r[0], "layer": r[1], "start": r[2], "end": r[3], "worker": r[4]} for r in rows + ] + + def layer_stats(self, run_id: str) -> list[dict[str, Any]]: + with self._lock: + rows = self._conn.execute( + 'SELECT layer, COUNT(*) AS n, SUM("end" - start) AS total, ' + 'MAX("end" - start) AS longest FROM task_spans WHERE run_id = %s ' + "GROUP BY layer ORDER BY total DESC", + (run_id,), + ).fetchall() + return [ + { + "layer": r[0], + "count": r[1], + "total_seconds": r[2] or 0.0, + "longest_seconds": r[3] or 0.0, + } + for r in rows + ] + + def latest_memory_by_worker(self) -> dict[str, MemorySample]: + with self._lock: + rows = self._conn.execute( + "SELECT DISTINCT ON (worker) worker, timestamp, rss_bytes, managed_bytes, " + "executing_keys FROM samples ORDER BY worker, timestamp DESC" + ).fetchall() + return { + r[0]: MemorySample( + timestamp=r[1], + rss_bytes=r[2], + managed_bytes=r[3], + executing_keys=json.loads(r[4]), + ) + for r in rows + } diff --git a/src/daskgenie/common/schemas.py b/src/daskgenie/common/schemas.py index d4bc27e..520396f 100644 --- a/src/daskgenie/common/schemas.py +++ b/src/daskgenie/common/schemas.py @@ -14,7 +14,11 @@ # v2: introduced the first-class "run" — every payload now carries a run_id. # v3: GraphUpload carries the full task graph (nodes/edges/task_count). # v4: SampleBatch carries task spans (per-task start/end) for the timeline. -SCHEMA_VERSION = 4 +# v5: deep memory (memray) — AllocationSite/MemoryEpoch/TaskMemory + live +# WorkerStatus heartbeats; DeathEvent gains suspect_sites. +# v6: MemoryEpoch carries full folded call stacks (AllocStack) for a real +# per-worker flamegraph / memray-style tree. +SCHEMA_VERSION = 6 class ChunkMeta(BaseModel): @@ -50,8 +54,91 @@ class TaskSpan(BaseModel): worker: str +class AllocationSite(BaseModel): + """One hot allocation source line, folded from a memray high-water-mark + stack to the first *user* frame (the same non-library frame the source map + keys on). ``hwm_bytes`` is the bytes live at the process high-water mark + attributed to this line — the number that adds up to an OOM. + + ``task_key``/``layer`` are filled when the owning epoch overlaps a single + task's span; otherwise blank (the line ran outside any tracked task). + """ + + filename: str + lineno: int + function: str + hwm_bytes: int + n_allocations: int = 0 + task_key: str = "" + layer: str = "" + + +class StackFrame(BaseModel): + """One frame of a call stack: the function and the source line it lives on.""" + + function: str + filename: str + lineno: int + + +class AllocStack(BaseModel): + """One full call path (root -> allocation site) and the high-water-mark bytes + attributed to it — the unit a flamegraph / memray-style tree is built from. + """ + + frames: list[StackFrame] = Field(default_factory=list) # root -> leaf + hwm_bytes: int + n_allocations: int = 0 + + +class MemoryEpoch(BaseModel): + """One memray rotation window on one worker: the high-water-mark allocation + sites (folded to the user line) and full call stacks live during + ``[start, end]``. Epochs give time-resolved attribution without an unbounded + capture file. + """ + + worker: str + start: float # unix epoch seconds + end: float + peak_rss: int + sites: list[AllocationSite] = Field(default_factory=list) + stacks: list[AllocStack] = Field(default_factory=list) + + +class TaskMemory(BaseModel): + """Per-task deep memory attribution: how much RSS the task added and the + allocation lines that dominated while it ran. + """ + + key: str + layer: str + worker: str + peak_rss_delta: int + top_sites: list[AllocationSite] = Field(default_factory=list) + + +class WorkerStatus(BaseModel): + """A live heartbeat for the Workers view — the native-dask-style snapshot of + one worker at one instant. ``memory_limit`` is 0 when unknown (local + schedulers have no per-worker limit). + """ + + worker: str + timestamp: float + rss_bytes: int + managed_bytes: int + memory_limit: int = 0 + cpu: float = 0.0 + nthreads: int = 0 + executing: int = 0 + ready: int = 0 + + class SampleBatch(BaseModel): - """A batch of samples + freshly-seen chunk metadata + task spans.""" + """A batch of samples + freshly-seen chunk metadata + task spans, plus the + optional deep-memory (memray) epochs / per-task memory and live statuses. + """ schema_version: int = Field(default=SCHEMA_VERSION) run_id: str @@ -59,6 +146,9 @@ class SampleBatch(BaseModel): samples: list[MemorySample] = Field(default_factory=list) chunks: list[ChunkMeta] = Field(default_factory=list) spans: list[TaskSpan] = Field(default_factory=list) + epochs: list[MemoryEpoch] = Field(default_factory=list) + task_memory: list[TaskMemory] = Field(default_factory=list) + statuses: list[WorkerStatus] = Field(default_factory=list) class GraphLayer(BaseModel): @@ -108,14 +198,21 @@ class DeathEvent(BaseModel): worker: str suspect_keys: list[str] = Field(default_factory=list) suspect_chunks: list[ChunkMeta] = Field(default_factory=list) + # Deep-memory attribution joined in by the collector: the allocation lines + # that were at the high-water mark when the worker died. + suspect_sites: list[AllocationSite] = Field(default_factory=list) suspected_oom: bool = False reason: str = "" class RunCreate(BaseModel): - """Client request to open a new run.""" + """Client request to open a new run. ``origin`` is the caller's hostname (so + a team dashboard shows which machine each run came from); the collector also + records the request IP. + """ name: str = "" + origin: str = "" class RunInfo(BaseModel): @@ -128,4 +225,6 @@ class RunInfo(BaseModel): id: str name: str created_at: float + origin: str = "" # caller hostname + origin_ip: str = "" # caller IP as seen by the collector counts: dict[str, int] = Field(default_factory=dict) diff --git a/src/daskgenie/deepmem/__init__.py b/src/daskgenie/deepmem/__init__.py new file mode 100644 index 0000000..84a6a61 --- /dev/null +++ b/src/daskgenie/deepmem/__init__.py @@ -0,0 +1,7 @@ +"""Deep memory profiling: memray driven as a library, epoch-rotated, folded to +the user source line responsible for each high-water-mark allocation. +""" + +from daskgenie.deepmem.tracker import DeepTracker + +__all__ = ["DeepTracker"] diff --git a/src/daskgenie/deepmem/tracker.py b/src/daskgenie/deepmem/tracker.py new file mode 100644 index 0000000..ab687a9 --- /dev/null +++ b/src/daskgenie/deepmem/tracker.py @@ -0,0 +1,349 @@ +"""The deep memory engine: memray driven *as a library*. + +memray is normally a CLI that leaves a ``.bin`` capture you post-process. Here +we drive it in-process so the user never sees a file: a background thread runs +memray in **epochs** — a short ``Tracker`` capture to a throwaway temp file, +stopped every ``epoch_seconds``, read back with ``FileReader``, folded to the +high-water-mark bytes per *user* source line, then the temp file is deleted and +the next epoch starts. Rotation keeps each capture tiny and, as a bonus, makes +the attribution time-resolved: each epoch's hot lines are correlated to the +tasks whose spans overlapped that window. + +Everything is guarded — memray import failure, tracker errors, unreadable +captures all degrade to "no deep data", never an exception into the worker. Only +one memray ``Tracker`` may be live per process, so a module singleton refuses a +second concurrent tracker. +""" + +from __future__ import annotations + +import logging +import os +import sysconfig +import tempfile +import threading +import time +from collections.abc import Callable, Sequence +from typing import Any + +import psutil + +from daskgenie.common.schemas import ( + AllocationSite, + AllocStack, + MemoryEpoch, + StackFrame, + TaskMemory, + TaskSpan, +) +from daskgenie.graphcapture import is_library_frame + +logger = logging.getLogger("daskgenie.deepmem") + +# The CPython standard-library directory (e.g. .../python3.12). memray stacks +# routinely pass through threading/queue/asyncio internals; those are no more +# "the user's code" than numpy is, so fold past them too. is_library_frame only +# knows the third-party packages, so we add the stdlib check here. +_STDLIB_DIRS = tuple( + d for d in {sysconfig.get_paths().get("stdlib"), sysconfig.get_paths().get("platstdlib")} if d +) + + +def _is_stdlib(filename: str) -> bool: + return any(filename.startswith(d) for d in _STDLIB_DIRS) and "site-packages" not in filename + + +# Directory of the daskgenie package, so the deep tracker never blames itself. +_DASKGENIE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Cap sites emitted per epoch so a pathological allocation pattern can't bloat a +# payload; the top lines by bytes are the only ones that matter for an OOM. +_MAX_SITES_PER_EPOCH = 40 +# Full call stacks are richer (a whole flamegraph), so cap count + depth harder. +_MAX_STACKS_PER_EPOCH = 60 +_MAX_STACK_DEPTH = 48 + +# One live memray Tracker per process, enforced across all DeepTracker uses. +_process_tracker_lock = threading.Lock() + + +def _memray_available() -> bool: + try: + import memray # noqa: F401 + except Exception: # noqa: BLE001 - unsupported platform / not installed + return False + return True + + +def _fold_to_user_line( + stack: Sequence[tuple[str, str, int]], extra_paths: Sequence[str] +) -> tuple[str, int, str] | None: + """Walk a memray stack (innermost first) to the first user frame → the line + that *caused* the allocation, skipping dask/numpy/site-packages internals. + """ + fallback: tuple[str, int, str] | None = None + for function, filename, lineno in stack: + if not filename or filename.startswith("<"): + continue + if _is_stdlib(filename) or _DASKGENIE_DIR in filename: + # stdlib internals and the profiler's own frames (the epoch thread's + # sleep, etc.) are never the user's allocation site. + continue + if fallback is None: + fallback = (filename, lineno, function) + if not is_library_frame(filename, extra_paths): + return (filename, lineno, function) + return fallback + + +def _clean_stack(stack: Sequence[tuple[str, str, int]]) -> list[StackFrame]: + """Turn a memray stack (innermost first) into root->leaf StackFrames for a + flamegraph. Drops the profiler's own frames and synthetic ``<...>`` frames + but keeps library frames (numpy/dask) so the tree reads like memray's.""" + frames: list[StackFrame] = [] + for function, filename, lineno in stack: + if not filename or filename.startswith("<"): + continue + if _DASKGENIE_DIR in filename: + continue + frames.append(StackFrame(function=function, filename=filename, lineno=lineno)) + frames.reverse() # memray gives leaf->root; a flamegraph reads root->leaf + if len(frames) > _MAX_STACK_DEPTH: + # keep the leaf end (where the allocation is) plus the outermost frame + frames = [frames[0], *frames[-(_MAX_STACK_DEPTH - 1) :]] + return frames + + +class DeepTracker: + """Epoch-rotating memray tracker. Owned by a worker/local profiler, drained + into its outbound ``SampleBatch``. + """ + + def __init__( + self, + *, + worker_label: str, + epoch_seconds: float = 5.0, + spans_source: Callable[[], list[TaskSpan]] | None = None, + executing_source: Callable[[], list[tuple[str, str]]] | None = None, + extra_library_paths: Sequence[str] = (), + ) -> None: + self.worker_label = worker_label + self.epoch_seconds = max(0.5, epoch_seconds) + self._spans_source = spans_source + # Returns (key, layer) for tasks executing *right now*. A task that OOMs + # never closes its span, so closed-span correlation alone would miss the + # very task that killed the worker — this catches it. + self._executing_source = executing_source + self._extra_paths = tuple(extra_library_paths) + self._proc = psutil.Process() + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._lock = threading.Lock() + self._epochs: list[MemoryEpoch] = [] + self._task_memory: list[TaskMemory] = [] + self._owns_tracker = False + + # -- lifecycle ---------------------------------------------------------- + + def start(self) -> None: + if not _memray_available(): + raise RuntimeError("memray not available") + if not _process_tracker_lock.acquire(blocking=False): + raise RuntimeError("another memray Tracker is already active in this process") + self._owns_tracker = True + self._stop.clear() + self._thread = threading.Thread(target=self._run, name="daskgenie-deepmem", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=self.epoch_seconds + 5.0) + if self._owns_tracker: + self._owns_tracker = False + try: + _process_tracker_lock.release() + except RuntimeError: + pass + + def drain(self) -> tuple[list[MemoryEpoch], list[TaskMemory]]: + with self._lock: + epochs = self._epochs + task_mem = self._task_memory + self._epochs = [] + self._task_memory = [] + return epochs, task_mem + + # -- epoch loop --------------------------------------------------------- + + def _run(self) -> None: + while not self._stop.is_set(): + try: + self._one_epoch() + except Exception: # noqa: BLE001 - deep profiling must never crash the job + logger.debug("deep epoch failed", exc_info=True) + # Back off a beat so a persistent failure doesn't spin hot. + self._stop.wait(self.epoch_seconds) + + def _one_epoch(self) -> None: + import memray + + tmp = os.path.join(tempfile.gettempdir(), f"daskgenie-{os.getpid()}-{time.time_ns()}.bin") + start = time.time() + try: + rss_start = self._proc.memory_info().rss + except Exception: # noqa: BLE001 + rss_start = 0 + tracker = memray.Tracker(destination=memray.FileDestination(tmp, overwrite=True)) + tracker.__enter__() + try: + self._stop.wait(self.epoch_seconds) + finally: + tracker.__exit__(None, None, None) + end = time.time() + try: + self._process_capture(tmp, start, end, rss_start) + finally: + try: + os.remove(tmp) + except OSError: + pass + + def _process_capture(self, path: str, start: float, end: float, rss_start: int) -> None: + import memray + + reader = memray.FileReader(path) + peak_rss = int(getattr(reader.metadata, "peak_memory", 0) or 0) + # Aggregate high-water-mark bytes per user source line, and separately + # per full call stack (for the flamegraph / tree view). + agg: dict[tuple[str, int, str], list[int]] = {} + stack_agg: dict[tuple[tuple[str, str, int], ...], list[Any]] = {} + for rec in reader.get_high_watermark_allocation_records(): + try: + raw = rec.stack_trace() + folded = _fold_to_user_line(raw, self._extra_paths) + except Exception: # noqa: BLE001 - a bad record shouldn't sink the epoch + continue + size = int(rec.size) + nalloc = int(rec.n_allocations) + if folded is not None: + slot = agg.setdefault(folded, [0, 0]) + slot[0] += size + slot[1] += nalloc + cleaned = _clean_stack(raw) + if cleaned: + skey = tuple((f.function, f.filename, f.lineno) for f in cleaned) + sslot = stack_agg.setdefault(skey, [0, 0, cleaned]) + sslot[0] += size + sslot[1] += nalloc + + if not agg and not stack_agg: + return + + stacks = [ + AllocStack(frames=frames, hwm_bytes=hwm, n_allocations=nalloc) + for (hwm, nalloc, frames) in sorted( + stack_agg.values(), key=lambda v: v[0], reverse=True + )[:_MAX_STACKS_PER_EPOCH] + ] + + keys, _ = self._tasks_in_window(start, end) + # Attribute this epoch's allocations to the task that dominated the + # window (most overlap), not only when a single task ran — otherwise + # short concurrent tasks leave every epoch "unattributed". + dom_key, dom_layer = self._dominant_in_window(start, end) + + sites = [ + AllocationSite( + filename=fn, + lineno=ln, + function=fun, + hwm_bytes=hwm, + n_allocations=nalloc, + task_key=dom_key, + layer=dom_layer, + ) + for (fn, ln, fun), (hwm, nalloc) in sorted( + agg.items(), key=lambda kv: kv[1][0], reverse=True + )[:_MAX_SITES_PER_EPOCH] + ] + epoch = MemoryEpoch( + worker=self.worker_label, + start=start, + end=end, + peak_rss=peak_rss, + sites=sites, + stacks=stacks, + ) + # Attribute this epoch's dominant lines to each task that ran in it. The + # RSS delta is the epoch's growth — shared across concurrent tasks, so + # it's an upper bound per task, not an exact split (documented). + delta = max(0, peak_rss - rss_start) + top = sites[:5] + task_mem = [ + TaskMemory( + key=key, + layer=self._layer_of_key(key, start, end), + worker=self.worker_label, + peak_rss_delta=delta, + top_sites=top, + ) + for key in keys + ] + with self._lock: + self._epochs.append(epoch) + self._task_memory.extend(task_mem) + + # -- span correlation --------------------------------------------------- + + def _spans_in_window(self, start: float, end: float) -> list[TaskSpan]: + if self._spans_source is None: + return [] + try: + spans = self._spans_source() + except Exception: # noqa: BLE001 + return [] + return [s for s in spans if s.start <= end and s.end >= start] + + def _executing_now(self) -> list[tuple[str, str]]: + if self._executing_source is None: + return [] + try: + return list(self._executing_source()) + except Exception: # noqa: BLE001 + return [] + + def _tasks_in_window(self, start: float, end: float) -> tuple[list[str], list[str]]: + pairs = {(s.key, s.layer) for s in self._spans_in_window(start, end)} + pairs |= set(self._executing_now()) # the still-running (maybe-dying) task + keys = sorted({k for k, _ in pairs}) + layers = sorted({ly for _, ly in pairs if ly}) + return keys, layers + + def _dominant_in_window(self, start: float, end: float) -> tuple[str, str]: + """The (key, layer) with the most overlap in [start, end]. A task still + executing at epoch end is weighted by the full window — it's the live + allocator, the one most likely responsible for the high-water mark. + """ + overlap: dict[tuple[str, str], float] = {} + for s in self._spans_in_window(start, end): + ov = min(s.end, end) - max(s.start, start) + if ov > 0: + overlap[(s.key, s.layer)] = overlap.get((s.key, s.layer), 0.0) + ov + span = max(end - start, 1e-6) + for k, ly in self._executing_now(): + overlap[(k, ly)] = overlap.get((k, ly), 0.0) + span + if not overlap: + return ("", "") + (key, layer), _ = max(overlap.items(), key=lambda kv: kv[1]) + return (key, layer) + + def _layer_of_key(self, key: str, start: float, end: float) -> str: + for s in self._spans_in_window(start, end): + if s.key == key: + return s.layer + for k, ly in self._executing_now(): + if k == key: + return ly + return "" diff --git a/src/daskgenie/graphcapture/__init__.py b/src/daskgenie/graphcapture/__init__.py index 5dcfd5c..ed70e9e 100644 --- a/src/daskgenie/graphcapture/__init__.py +++ b/src/daskgenie/graphcapture/__init__.py @@ -2,7 +2,13 @@ code that built them. """ -from daskgenie.graphcapture.capture import SourceLocation, get_layer_map, track, watch +from daskgenie.graphcapture.capture import ( + SourceLocation, + get_layer_map, + is_library_frame, + track, + watch, +) from daskgenie.graphcapture.extract import GraphInfo, TaskGraph, extract_graph, extract_task_graph __all__ = [ @@ -12,6 +18,7 @@ "track", "watch", "get_layer_map", + "is_library_frame", "extract_graph", "extract_task_graph", ] diff --git a/src/daskgenie/graphcapture/capture.py b/src/daskgenie/graphcapture/capture.py index ca9b17a..07fb93b 100644 --- a/src/daskgenie/graphcapture/capture.py +++ b/src/daskgenie/graphcapture/capture.py @@ -57,13 +57,23 @@ class SourceLocation: _original_from_collections: Callable[..., HighLevelGraph] = _ORIGINAL_DESCRIPTOR.__func__ -def _is_library_frame(filename: str) -> bool: +def is_library_frame(filename: str, extra_library_paths: Sequence[str] = ()) -> bool: + """True if ``filename`` is library code (dask/numpy/site-packages/...) rather + than the user's own source. Shared by graph source-attribution and the deep + memory engine (which folds memray stacks to the first *user* frame). + """ parts = Path(filename).parts if "site-packages" in parts or "dist-packages" in parts: return True if any(module in parts for module in _DEFAULT_LIBRARY_MODULES): return True - return any(extra in filename for extra in _extra_library_paths) + if any(extra in filename for extra in _extra_library_paths): + return True + return any(extra in filename for extra in extra_library_paths) + + +def _is_library_frame(filename: str) -> bool: + return is_library_frame(filename) def _find_user_frame() -> FrameType | None: @@ -77,13 +87,44 @@ def _find_user_frame() -> FrameType | None: return None +def _read_statement(filename: str, lineno: int, max_lines: int = 8, max_chars: int = 240) -> str: + """The full logical statement at ``lineno``, not just its first physical + line — a call split across lines (``map_overlap(\\n ...,\\n)``) would + otherwise be captured as a bare ``x = x.map_overlap(``. Follows bracket + depth across continuation lines and collapses them into one readable line. + """ + parts: list[str] = [] + depth = 0 + opened = False + for i in range(lineno, lineno + max_lines): + raw = linecache.getline(filename, i) + if not raw: + break + parts.append(raw.strip()) + for ch in raw: + if ch in "([{": + depth += 1 + opened = True + elif ch in ")]}": + depth -= 1 + text = " ".join(p for p in parts if p) + if len(text) >= max_chars: + break + # stop once brackets balance (or the first line had none at all) + if depth <= 0: + break + if not opened: + break + return " ".join(p for p in parts if p)[:max_chars] + + def _capture_source_location() -> SourceLocation | None: frame = _find_user_frame() if frame is None: return None filename = frame.f_code.co_filename lineno = frame.f_lineno - snippet = linecache.getline(filename, lineno).strip() + snippet = _read_statement(filename, lineno) return SourceLocation(filename=filename, lineno=lineno, code_snippet=snippet) diff --git a/src/daskgenie/graphcapture/extract.py b/src/daskgenie/graphcapture/extract.py index 908b7f5..ec1ede1 100644 --- a/src/daskgenie/graphcapture/extract.py +++ b/src/daskgenie/graphcapture/extract.py @@ -37,9 +37,11 @@ def _layer_of(key: object) -> str: return str(key[0]) if isinstance(key, tuple) and key else str(key) -def extract_task_graph(collection: object, *, max_nodes: int = 3000) -> TaskGraph: +def extract_task_graph(collection: object, *, max_nodes: int = 8000) -> TaskGraph: """Build the full task graph from a collection, capped so a huge graph can't - blow up the payload or the browser — callers fall back to the layer view. + blow up the payload or the browser. The dashboard renders task graphs up to + this size on a pan/zoom canvas; above it, callers fall back to the layer + view (``truncated=True``). """ from daskgenie.common.arrays import key_str @@ -51,10 +53,7 @@ def extract_task_graph(collection: object, *, max_nodes: int = 3000) -> TaskGrap flat = dict(dask_graph) nodes = [(key_str(k), _layer_of(k)) for k in flat] edges = [ - (key_str(dep), key_str(k)) - for k in flat - for dep in get_dependencies(flat, k) - if dep in flat + (key_str(dep), key_str(k)) for k in flat for dep in get_dependencies(flat, k) if dep in flat ] return TaskGraph(nodes=nodes, edges=edges, task_count=count, truncated=False) diff --git a/src/daskgenie/local_profiler.py b/src/daskgenie/local_profiler.py index cbfea65..9280ba8 100644 --- a/src/daskgenie/local_profiler.py +++ b/src/daskgenie/local_profiler.py @@ -32,6 +32,7 @@ import os import threading import time +from collections import deque from collections.abc import Mapping from typing import Any @@ -40,7 +41,15 @@ from daskgenie import report from daskgenie.common.arrays import describe_array, key_str, layer_of -from daskgenie.common.schemas import ChunkMeta, MemorySample, SampleBatch, TaskSpan +from daskgenie.common.schemas import ( + ChunkMeta, + MemoryEpoch, + MemorySample, + SampleBatch, + TaskMemory, + TaskSpan, + WorkerStatus, +) from daskgenie.graphcapture import SourceLocation logger = logging.getLogger("daskgenie.local_profiler") @@ -59,12 +68,16 @@ def __init__( collection: object | None = None, sample_interval: float = 0.1, worker_label: str | None = None, + deep: bool = False, + deep_epoch_seconds: float = 5.0, ) -> None: super().__init__() # type: ignore[no-untyped-call] self.collector_url = collector_url.rstrip("/") self.sample_interval = sample_interval self.source_map = source_map self.collection = collection + self.deep = deep + self.deep_epoch_seconds = deep_epoch_seconds # One process, so one "worker" line on the memory chart. Default label # names the process so multiple hosts stay distinguishable. self.worker_label = worker_label or f"local-pid-{os.getpid()}" @@ -76,6 +89,11 @@ def __init__( self._samples: list[MemorySample] = [] self._chunks: list[ChunkMeta] = [] self._spans: list[TaskSpan] = [] + self._recent_spans: deque[TaskSpan] = deque(maxlen=_MAX_BUFFER) + self._statuses: list[WorkerStatus] = [] + self._epochs: list[MemoryEpoch] = [] + self._task_memory: list[TaskMemory] = [] + self._deep: Any = None self._lock = threading.Lock() self._stop = threading.Event() self._thread: threading.Thread | None = None @@ -85,12 +103,37 @@ def __init__( def __enter__(self) -> LocalProfiler: super().__enter__() # type: ignore[no-untyped-call] # register the callback hooks self._stop.clear() + self._start_deep() self._thread = threading.Thread(target=self._run, name="daskgenie-local", daemon=True) self._thread.start() return self + def _start_deep(self) -> None: + self._deep = None + if not self.deep: + return + try: + from daskgenie.deepmem import DeepTracker + + self._deep = DeepTracker( + worker_label=self.worker_label, + epoch_seconds=self.deep_epoch_seconds, + spans_source=lambda: list(self._recent_spans), + executing_source=lambda: [(k, layer_of(k)) for k in sorted(self._running)], + ) + self._deep.start() + except Exception: # noqa: BLE001 - deep is opt-in; degrade to Tier-1 + logger.debug("deep tracker unavailable, continuing Tier-1 only", exc_info=True) + self._deep = None + def __exit__(self, *exc: object) -> None: try: + if self._deep is not None: + try: + self._deep.stop() + self._collect_deep() + except Exception: # noqa: BLE001 + logger.debug("deep teardown failed", exc_info=True) if self._thread is not None: self._stop.set() self._thread.join(timeout=self.sample_interval + 5.0) @@ -120,39 +163,76 @@ def _posttask(self, key: Any, result: Any, dsk: Any, state: Any, worker_id: Any) if meta is not None and len(self._chunks) < _MAX_BUFFER: self._chunks.append(meta) if start is not None and len(self._spans) < _MAX_BUFFER: - self._spans.append( - TaskSpan( - key=sk, - layer=layer_of(key), - start=start, - end=now, - worker=self.worker_label, - ) + span = TaskSpan( + key=sk, + layer=layer_of(key), + start=start, + end=now, + worker=self.worker_label, ) + self._spans.append(span) + self._recent_spans.append(span) # -- sampler ------------------------------------------------------------- def _sample(self) -> None: try: rss = self._proc.memory_info().rss + cpu = self._proc.cpu_percent(None) except Exception: # noqa: BLE001 - degrade to no data, never crash the job logger.debug("sample failed", exc_info=True) return + now = time.time() with self._lock: executing = sorted(self._running) if len(self._samples) < _MAX_BUFFER: self._samples.append( MemorySample( - timestamp=time.time(), + timestamp=now, rss_bytes=rss, managed_bytes=0, executing_keys=executing, ) ) + if len(self._statuses) < _MAX_BUFFER: + self._statuses.append( + WorkerStatus( + worker=self.worker_label, + timestamp=now, + rss_bytes=rss, + managed_bytes=0, + memory_limit=0, + cpu=cpu, + nthreads=self._proc.num_threads(), + executing=len(executing), + ready=0, + ) + ) + + def _collect_deep(self) -> None: + if self._deep is None: + return + try: + epochs, task_mem = self._deep.drain() + except Exception: # noqa: BLE001 + logger.debug("deep drain failed", exc_info=True) + return + with self._lock: + self._epochs.extend(epochs) + self._task_memory.extend(task_mem) def _drain(self) -> SampleBatch | None: with self._lock: - if not self._samples and not self._chunks and not self._spans: + if not any( + ( + self._samples, + self._chunks, + self._spans, + self._statuses, + self._epochs, + self._task_memory, + ) + ): return None batch = SampleBatch( run_id=self.run_id, @@ -160,10 +240,16 @@ def _drain(self) -> SampleBatch | None: samples=list(self._samples), chunks=list(self._chunks), spans=list(self._spans), + statuses=list(self._statuses), + epochs=list(self._epochs), + task_memory=list(self._task_memory), ) self._samples.clear() self._chunks.clear() self._spans.clear() + self._statuses.clear() + self._epochs.clear() + self._task_memory.clear() return batch def _flush(self) -> None: @@ -179,6 +265,7 @@ def _run(self) -> None: last_flush = time.monotonic() while not self._stop.is_set(): self._sample() + self._collect_deep() if time.monotonic() - last_flush >= 1.0: self._flush() last_flush = time.monotonic() diff --git a/src/daskgenie/report.py b/src/daskgenie/report.py index 28529d2..8335338 100644 --- a/src/daskgenie/report.py +++ b/src/daskgenie/report.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import socket import urllib.request from collections.abc import Mapping, Sequence @@ -25,8 +26,13 @@ def _post(url: str, payload: bytes, *, timeout: float = 10.0) -> bytes: def create_run(collector_url: str, name: str = "") -> str: - """Open a run on the collector and return its id.""" - body = json.dumps({"name": name}).encode("utf-8") + """Open a run on the collector and return its id. Tags the run with this + machine's hostname so a shared dashboard shows where each run came from.""" + try: + origin = socket.gethostname() + except Exception: # noqa: BLE001 + origin = "" + body = json.dumps({"name": name, "origin": origin}).encode("utf-8") raw = _post(f"{collector_url.rstrip('/')}/api/runs", body) return str(json.loads(raw)["id"]) diff --git a/src/daskgenie/worker_plugin/plugin.py b/src/daskgenie/worker_plugin/plugin.py index b94799e..e6d3584 100644 --- a/src/daskgenie/worker_plugin/plugin.py +++ b/src/daskgenie/worker_plugin/plugin.py @@ -27,7 +27,15 @@ from distributed.diagnostics.plugin import WorkerPlugin from daskgenie.common.arrays import describe_array, key_str, layer_of -from daskgenie.common.schemas import ChunkMeta, MemorySample, SampleBatch, TaskSpan +from daskgenie.common.schemas import ( + ChunkMeta, + MemoryEpoch, + MemorySample, + SampleBatch, + TaskMemory, + TaskSpan, + WorkerStatus, +) if TYPE_CHECKING: from dask.typing import Key @@ -54,11 +62,16 @@ class MemoryProfilerPlugin(WorkerPlugin): _samples: deque[MemorySample] _chunks: deque[ChunkMeta] _spans: deque[TaskSpan] + _recent_spans: deque[TaskSpan] + _statuses: deque[WorkerStatus] + _epochs: deque[MemoryEpoch] + _task_memory: deque[TaskMemory] _starts: dict[str, float] _seen_chunk_keys: set[tuple[str, str]] _lock: threading.Lock _stop: threading.Event _thread: threading.Thread | None = None + _deep: Any = None # DeepTracker | None, created in setup() when deep=True def __init__( self, @@ -66,8 +79,10 @@ def __init__( run_id: str, *, sample_interval: float = 0.2, - flush_interval: float = 2.0, + flush_interval: float = 0.5, http_timeout: float = 5.0, + deep: bool = False, + deep_epoch_seconds: float = 5.0, ) -> None: # Only config lives here: the plugin is pickled and shipped to every # worker, so it must not hold locks/threads/deques (they aren't @@ -77,6 +92,8 @@ def __init__( self.sample_interval = sample_interval self.flush_interval = flush_interval self.http_timeout = http_timeout + self.deep = deep + self.deep_epoch_seconds = deep_epoch_seconds # -- WorkerPlugin hooks ------------------------------------------------- @@ -86,17 +103,54 @@ def setup(self, worker: Worker) -> None: self._samples: deque[MemorySample] = deque(maxlen=_MAX_SAMPLES) self._chunks: deque[ChunkMeta] = deque(maxlen=_MAX_CHUNKS) self._spans: deque[TaskSpan] = deque(maxlen=_MAX_CHUNKS) + # Retained across flushes so the deep tracker can correlate an epoch to + # the tasks that ran in its window (the outbound _spans is cleared each + # flush, ~10x more often than an epoch closes). + self._recent_spans: deque[TaskSpan] = deque(maxlen=_MAX_CHUNKS) + self._statuses: deque[WorkerStatus] = deque(maxlen=_MAX_SAMPLES) + self._epochs: deque[MemoryEpoch] = deque(maxlen=_MAX_CHUNKS) + self._task_memory: deque[TaskMemory] = deque(maxlen=_MAX_CHUNKS) self._starts: dict[str, float] = {} # dedup on (consumer_key, input_key) so re-entry doesn't re-record self._seen_chunk_keys: set[tuple[str, str]] = set() self._lock = threading.Lock() self._stop = threading.Event() + # prime cpu_percent so the first real reading isn't a meaningless 0.0 + try: + self._proc.cpu_percent(None) + except Exception: # noqa: BLE001 + pass + self._start_deep() self._thread = threading.Thread(target=self._run, name="daskgenie-sampler", daemon=True) self._thread.start() + def _start_deep(self) -> None: + self._deep = None + if not self.deep: + return + try: + from daskgenie.deepmem import DeepTracker + + self._deep = DeepTracker( + worker_label=self._worker.address if self._worker else "", + epoch_seconds=self.deep_epoch_seconds, + spans_source=lambda: list(self._recent_spans), + executing_source=self._executing_now, + ) + self._deep.start() + except Exception: # noqa: BLE001 - deep is opt-in; degrade to Tier-1 + logger.debug("deep tracker unavailable, continuing Tier-1 only", exc_info=True) + self._deep = None + def teardown(self, worker: Worker) -> None: if self._thread is None: # setup never ran return + if self._deep is not None: + try: + self._deep.stop() + self._collect_deep() + except Exception: # noqa: BLE001 + logger.debug("deep teardown failed", exc_info=True) self._stop.set() self._thread.join(timeout=self.flush_interval + self.http_timeout) self._flush() # best-effort final drain @@ -134,6 +188,7 @@ def _close_span(self, key: Key) -> None: ) with self._lock: self._spans.append(span) + self._recent_spans.append(span) # -- internals ---------------------------------------------------------- @@ -167,6 +222,7 @@ def _sample(self) -> None: worker = self._worker if worker is None: return + now = time.time() try: rss = self._proc.memory_info().rss managed = int(getattr(worker.state, "nbytes", 0)) @@ -175,20 +231,82 @@ def _sample(self) -> None: logger.debug("sample failed", exc_info=True) return sample = MemorySample( - timestamp=time.time(), + timestamp=now, rss_bytes=rss, managed_bytes=managed, executing_keys=executing, ) + status = self._status(worker, now, rss, managed, len(executing)) with self._lock: self._samples.append(sample) + if status is not None: + self._statuses.append(status) + + def _status( + self, worker: Worker, now: float, rss: int, managed: int, executing: int + ) -> WorkerStatus | None: + """A live heartbeat for the Workers view — best-effort, never raises.""" + try: + cpu = self._proc.cpu_percent(None) + nthreads = int(getattr(worker, "nthreads", 0) or 0) + ready = len(getattr(worker.state, "ready", ())) + limit = int(getattr(getattr(worker, "memory_manager", None), "memory_limit", 0) or 0) + except Exception: # noqa: BLE001 + return None + return WorkerStatus( + worker=worker.address, + timestamp=now, + rss_bytes=rss, + managed_bytes=managed, + memory_limit=limit, + cpu=cpu, + nthreads=nthreads, + executing=executing, + ready=ready, + ) + + def _executing_now(self) -> list[tuple[str, str]]: + """(key, layer) for tasks executing on this worker right now — lets the + deep tracker attribute an epoch to a task that is still running (and may + be about to OOM), not only to tasks whose spans have already closed.""" + worker = self._worker + if worker is None: + return [] + try: + return [(key_str(ts.key), layer_of(ts.key)) for ts in worker.state.executing] + except Exception: # noqa: BLE001 + return [] + + def _collect_deep(self) -> None: + """Drain finished memray epochs + per-task memory from the deep tracker.""" + if self._deep is None: + return + try: + epochs, task_mem = self._deep.drain() + except Exception: # noqa: BLE001 + logger.debug("deep drain failed", exc_info=True) + return + if not epochs and not task_mem: + return + with self._lock: + self._epochs.extend(epochs) + self._task_memory.extend(task_mem) def _drain(self) -> SampleBatch | None: worker = self._worker if worker is None: return None with self._lock: - if not self._samples and not self._chunks and not self._spans: + if not any( + ( + self._samples, + self._chunks, + self._spans, + self._statuses, + self._epochs, + self._task_memory, + ) + ): return None batch = SampleBatch( run_id=self.run_id, @@ -196,10 +314,16 @@ def _drain(self) -> SampleBatch | None: samples=list(self._samples), chunks=list(self._chunks), spans=list(self._spans), + statuses=list(self._statuses), + epochs=list(self._epochs), + task_memory=list(self._task_memory), ) self._samples.clear() self._chunks.clear() self._spans.clear() + self._statuses.clear() + self._epochs.clear() + self._task_memory.clear() return batch def _flush(self) -> None: @@ -226,6 +350,7 @@ def _run(self) -> None: last_flush = time.monotonic() while not self._stop.is_set(): self._sample() + self._collect_deep() now = time.monotonic() if now - last_flush >= self.flush_interval: self._flush() diff --git a/tests/test_collector.py b/tests/test_collector.py index b0d48b2..53d7df8 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -6,12 +6,16 @@ from daskgenie.collector.store import Store from daskgenie.common.schemas import ( SCHEMA_VERSION, + AllocationSite, ChunkMeta, DeathEvent, GraphLayer, GraphUpload, + MemoryEpoch, MemorySample, SampleBatch, + TaskMemory, + WorkerStatus, ) RUN = "run1" @@ -199,3 +203,129 @@ def test_death_event_is_enriched_with_stored_chunk_metadata() -> None: assert len(death["suspect_chunks"]) == 1 assert death["suspect_chunks"][0]["nbytes"] == 512_000_000 assert death["suspect_chunks"][0]["task_key"] == "rechunk-merge-1" + + +def test_worker_status_returns_latest_per_worker() -> None: + client = _client() + for ts, cpu in [(1.0, 10.0), (2.0, 55.0)]: + client.post( + "/ingest/samples", + json=SampleBatch( + run_id=RUN, + worker="tcp://w1", + statuses=[ + WorkerStatus( + worker="tcp://w1", + timestamp=ts, + rss_bytes=int(ts), + managed_bytes=0, + memory_limit=1000, + cpu=cpu, + nthreads=4, + executing=2, + ready=3, + ) + ], + ).model_dump(), + ) + workers = client.get(f"/api/runs/{RUN}/workers").json() + assert len(workers) == 1 + assert workers[0]["cpu"] == 55.0 # most recent heartbeat wins + assert workers[0]["executing"] == 2 + + +def test_alloc_sites_peak_per_line_across_epochs() -> None: + client = _client() + site = lambda hwm: AllocationSite( # noqa: E731 + filename="job.py", lineno=42, function="build", hwm_bytes=hwm, n_allocations=1 + ) + client.post( + "/ingest/samples", + json=SampleBatch( + run_id=RUN, + worker="w1", + epochs=[ + MemoryEpoch(worker="w1", start=0.0, end=1.0, peak_rss=100, sites=[site(100)]), + MemoryEpoch(worker="w1", start=1.0, end=2.0, peak_rss=300, sites=[site(300)]), + ], + ).model_dump(), + ) + sites = client.get(f"/api/runs/{RUN}/alloc-sites").json() + assert len(sites) == 1 + # peak across disjoint epochs is the MAX, not the sum + assert sites[0]["hwm_bytes"] == 300 + assert sites[0]["lineno"] == 42 + + +def test_task_memory_roundtrip() -> None: + client = _client() + client.post( + "/ingest/samples", + json=SampleBatch( + run_id=RUN, + worker="w1", + task_memory=[ + TaskMemory( + key="build-0", + layer="build", + worker="w1", + peak_rss_delta=128_000_000, + top_sites=[ + AllocationSite( + filename="job.py", lineno=7, function="build", hwm_bytes=128_000_000 + ) + ], + ) + ], + ).model_dump(), + ) + tm = client.get(f"/api/runs/{RUN}/task-memory").json() + assert tm[0]["key"] == "build-0" + assert tm[0]["peak_rss_delta"] == 128_000_000 + assert tm[0]["top_sites"][0]["lineno"] == 7 + + +def test_death_enriched_with_alloc_sites() -> None: + """The deep engine records which line was at the high-water mark per task; + a death join surfaces it as the cause, alongside the chunk view. + """ + client = _client() + client.post( + "/ingest/samples", + json=SampleBatch( + run_id=RUN, + worker="tcp://w1", + epochs=[ + MemoryEpoch( + worker="tcp://w1", + start=0.0, + end=1.0, + peak_rss=8_000_000_000, + sites=[ + AllocationSite( + filename="job.py", + lineno=42, + function="build", + hwm_bytes=8_000_000_000, + task_key="build-0", + layer="build", + ) + ], + ) + ], + ).model_dump(), + ) + client.post( + "/ingest/death", + json=DeathEvent( + run_id=RUN, + timestamp=1.0, + worker="tcp://w1", + suspect_keys=["build-0"], + suspected_oom=True, + ).model_dump(), + ) + death = client.get(f"/api/runs/{RUN}/deaths").json()[0] + assert len(death["suspect_sites"]) == 1 + assert death["suspect_sites"][0]["lineno"] == 42 + assert death["suspect_sites"][0]["hwm_bytes"] == 8_000_000_000 diff --git a/tests/test_integration_deep.py b/tests/test_integration_deep.py new file mode 100644 index 0000000..80f3ce0 --- /dev/null +++ b/tests/test_integration_deep.py @@ -0,0 +1,93 @@ +"""End-to-end: a real (processes=True) LocalCluster runs a job with the memray +deep engine on, and we assert the per-source-line allocation attribution reaches +the collector — the "which line allocated the big array" headline, on the real +distributed worker path. +""" + +from __future__ import annotations + +# Guarded imports below need importorskip first, so E402 is expected here. +# ruff: noqa: E402 +import socket +import threading +import time + +import pytest + +uvicorn = pytest.importorskip("uvicorn") +np = pytest.importorskip("numpy") +distributed = pytest.importorskip("distributed") +pytest.importorskip("memray") + +from daskgenie.collector.app import create_app +from daskgenie.collector.store import Store + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +class _ServerThread: + def __init__(self, app: object, port: int) -> None: + config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning") + self.server = uvicorn.Server(config) + self.thread = threading.Thread(target=self.server.run, daemon=True) + + def __enter__(self) -> _ServerThread: + self.thread.start() + for _ in range(100): + if self.server.started: + return self + time.sleep(0.05) + raise RuntimeError("collector did not start") + + def __exit__(self, *exc: object) -> None: + self.server.should_exit = True + self.thread.join(timeout=5) + + +def _big_alloc(block): # type: ignore[no-untyped-def] + junk = np.ones((3000, 3000), dtype="float64") # ~72 MB on THIS line + time.sleep(0.15) + return block + junk.sum() * 0.0 + + +@pytest.mark.integration +def test_deep_engine_attributes_allocation_to_source_line() -> None: + import dask.array as da + from distributed import Client, LocalCluster + + import daskgenie as dg + import daskgenie.client as dg_client + + store = Store(":memory:") + port = _free_port() + url = f"http://127.0.0.1:{port}" + + with _ServerThread(create_app(store), port): + with ( + LocalCluster( + n_workers=1, threads_per_worker=1, processes=True, dashboard_address=":0" + ) as cluster, + Client(cluster) as client, + ): + run_id = dg_client.register( + client, url, sample_interval=0.05, deep=True, deep_epoch_seconds=1.0 + ) + time.sleep(0.6) # let the plugin + memray tracker install + + with dg.track(): + x = da.ones((6000, 6000), chunks=(3000, 3000)) + x.map_blocks(_big_alloc, dtype="float64").sum().compute() + + time.sleep(3.0) # let an epoch close and flush + + sites = store.alloc_sites(run_id) + assert sites, "no deep allocation sites reached the collector" + # the biggest allocation must be attributed to our _big_alloc line + top = sites[0] + assert top["function"] == "_big_alloc" + assert top["filename"].endswith("test_integration_deep.py") + assert top["hwm_bytes"] > 50_000_000 diff --git a/uv.lock b/uv.lock index 535c72d..002a943 100644 --- a/uv.lock +++ b/uv.lock @@ -2,9 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.12' and python_full_version < '3.15'", - "python_full_version < '3.12'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -151,13 +160,32 @@ dependencies = [ collector = [ { name = "fastapi" }, { name = "prometheus-client" }, - { name = "uvicorn" }, + { name = "psycopg", extra = ["binary"] }, + { name = "uvicorn", extra = ["standard"] }, + { name = "websockets" }, +] +deep = [ + { name = "memray" }, ] demo = [ { name = "distributed" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] +examples = [ + { name = "distributed" }, + { name = "h5netcdf" }, + { name = "h5py" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas" }, + { name = "pyarrow" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "xarray" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "zarr", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] [package.dev-dependencies] dev = [ @@ -165,28 +193,43 @@ dev = [ { name = "fastapi" }, { name = "httpx" }, { name = "jinja2" }, + { name = "memray" }, { name = "mypy" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "prometheus-client" }, + { name = "psycopg", extra = ["binary"] }, { name = "pytest" }, { name = "ruff" }, { name = "types-psutil" }, - { name = "uvicorn" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "websockets" }, ] [package.metadata] requires-dist = [ { name = "dask", specifier = ">=2024.1.0" }, { name = "distributed", marker = "extra == 'demo'", specifier = ">=2024.1.0" }, + { name = "distributed", marker = "extra == 'examples'", specifier = ">=2024.1.0" }, { name = "fastapi", marker = "extra == 'collector'", specifier = ">=0.110" }, + { name = "h5netcdf", marker = "extra == 'examples'", specifier = ">=1.3" }, + { name = "h5py", marker = "extra == 'examples'", specifier = ">=3.10" }, + { name = "memray", marker = "extra == 'deep'", specifier = ">=1.11" }, { name = "numpy", marker = "extra == 'demo'", specifier = ">=1.26" }, + { name = "numpy", marker = "extra == 'examples'", specifier = ">=1.26" }, + { name = "pandas", marker = "extra == 'examples'", specifier = ">=2.2" }, { name = "prometheus-client", marker = "extra == 'collector'", specifier = ">=0.20" }, { name = "psutil", specifier = ">=5.9" }, + { name = "psycopg", extras = ["binary"], marker = "extra == 'collector'", specifier = ">=3.1" }, + { name = "pyarrow", marker = "extra == 'examples'", specifier = ">=15" }, { name = "pydantic", specifier = ">=2.6" }, - { name = "uvicorn", marker = "extra == 'collector'", specifier = ">=0.29" }, + { name = "scipy", marker = "extra == 'examples'", specifier = ">=1.12" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'collector'", specifier = ">=0.29" }, + { name = "websockets", marker = "extra == 'collector'", specifier = ">=12.0" }, + { name = "xarray", marker = "extra == 'examples'", specifier = ">=2024.1" }, + { name = "zarr", marker = "extra == 'examples'", specifier = ">=2.17" }, ] -provides-extras = ["collector", "demo"] +provides-extras = ["collector", "deep", "demo", "examples"] [package.metadata.requires-dev] dev = [ @@ -194,13 +237,16 @@ dev = [ { name = "fastapi", specifier = ">=0.110" }, { name = "httpx", specifier = ">=0.27" }, { name = "jinja2", specifier = ">=3.1" }, + { name = "memray", specifier = ">=1.11" }, { name = "mypy", specifier = ">=1.10" }, { name = "numpy", specifier = ">=1.26" }, { name = "prometheus-client", specifier = ">=0.20" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.1" }, { name = "pytest", specifier = ">=8.0" }, { name = "ruff", specifier = ">=0.6" }, { name = "types-psutil", specifier = ">=6.0" }, - { name = "uvicorn", specifier = ">=0.29" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.29" }, + { name = "websockets", specifier = ">=12.0" }, ] [[package]] @@ -228,6 +274,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl", hash = "sha256:ca034e8b4d05b8a92f0655ebd0914e82308509a213889456d7ddd2bd0543439b", size = 1006995, upload-time = "2026-06-11T17:55:58.374Z" }, ] +[[package]] +name = "donfig" +version = "0.8.1.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/71/80cc718ff6d7abfbabacb1f57aaa42e9c1552bfdd01e64ddd704e4a03638/donfig-0.8.1.post1.tar.gz", hash = "sha256:3bef3413a4c1c601b585e8d297256d0c1470ea012afa6e8461dc28bfb7c23f52", size = 19506, upload-time = "2024-05-23T14:14:31.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592, upload-time = "2024-05-23T14:13:55.283Z" }, +] + [[package]] name = "fastapi" version = "0.139.0" @@ -253,6 +311,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] +[[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/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -262,6 +350,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h5netcdf" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/03/92d6cc02c0055158167255980461155d6e17f1c4143c03f8bcc18d3e3f3a/h5netcdf-1.8.1.tar.gz", hash = "sha256:9b396a4cc346050fc1a4df8523bc1853681ec3544e0449027ae397cb953c7a16", size = 78679, upload-time = "2026-01-23T07:35:31.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/8b/88f16936a8e8070a83d36239555227ecd91728f9ef222c5382cda07e0fd6/h5netcdf-1.8.1-py3-none-any.whl", hash = "sha256:a76ed7cfc9b8a8908ea7057c4e57e27307acff1049b7f5ed52db6c2247636879", size = 62915, upload-time = "2026-01-23T07:35:30.195Z" }, +] + +[[package]] +name = "h5py" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526, upload-time = "2026-03-06T13:49:08.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/95/a825894f3e45cbac7554c4e97314ce886b233a20033787eda755ca8fecc7/h5py-3.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:719439d14b83f74eeb080e9650a6c7aa6d0d9ea0ca7f804347b05fac6fbf18af", size = 3721663, upload-time = "2026-03-06T13:47:49.599Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/38ff88b347c3e346cda1d3fc1b65a7aa75d40632228d8b8a5d7b58508c24/h5py-3.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3f0a0e136f2e95dd0b67146abb6668af4f1a69c81ef8651a2d316e8e01de447", size = 3087630, upload-time = "2026-03-06T13:47:51.249Z" }, + { url = "https://files.pythonhosted.org/packages/98/a8/2594cef906aee761601eff842c7dc598bea2b394a3e1c00966832b8eeb7c/h5py-3.16.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a6fbc5367d4046801f9b7db9191b31895f22f1c6df1f9987d667854cac493538", size = 4823472, upload-time = "2026-03-06T13:47:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/c1f604538ff6db22a0690be2dc44ab59178e115f63c917794e529356ab23/h5py-3.16.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fb1720028d99040792bb2fb31facb8da44a6f29df7697e0b84f0d79aff2e9bd3", size = 5027150, upload-time = "2026-03-06T13:47:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fd/301739083c2fc4fd89950f9bcfce75d6e14b40b0ca3d40e48a8993d1722c/h5py-3.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:314b6054fe0b1051c2b0cb2df5cbdab15622fb05e80f202e3b6a5eee0d6fe365", size = 4814544, upload-time = "2026-03-06T13:47:56.893Z" }, + { url = "https://files.pythonhosted.org/packages/4c/42/2193ed41ccee78baba8fcc0cff2c925b8b9ee3793305b23e1f22c20bf4c7/h5py-3.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ffbab2fedd6581f6aa31cf1639ca2cb86e02779de525667892ebf4cc9fd26434", size = 5034013, upload-time = "2026-03-06T13:47:59.01Z" }, + { url = "https://files.pythonhosted.org/packages/f7/20/e6c0ff62ca2ad1a396a34f4380bafccaaf8791ff8fccf3d995a1fc12d417/h5py-3.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:17d1f1630f92ad74494a9a7392ab25982ce2b469fc62da6074c0ce48366a2999", size = 3191673, upload-time = "2026-03-06T13:48:00.626Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/239cbe352ac4f2b8243a8e620fa1a2034635f633731493a7ff1ed71e8658/h5py-3.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b9c49dd58dc44cf70af944784e2c2038b6f799665d0dcbbc812a26e0faa859", size = 2673834, upload-time = "2026-03-06T13:48:02.579Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c0/5d4119dba94093bbafede500d3defd2f5eab7897732998c04b54021e530b/h5py-3.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5313566f4643121a78503a473f0fb1e6dcc541d5115c44f05e037609c565c4d", size = 3685604, upload-time = "2026-03-06T13:48:04.198Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:42b012933a83e1a558c673176676a10ce2fd3759976a0fedee1e672d1e04fc9d", size = 3061940, upload-time = "2026-03-06T13:48:05.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/84/06281c82d4d1686fde1ac6b0f307c50918f1c0151062445ab3b6fa5a921d/h5py-3.16.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ff24039e2573297787c3063df64b60aab0591980ac898329a08b0320e0cf2527", size = 5198852, upload-time = "2026-03-06T13:48:07.482Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/1a19e42cd43cc1365e127db6aae85e1c671da1d9a5d746f4d34a50edb577/h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:dfc21898ff025f1e8e67e194965a95a8d4754f452f83454538f98f8a3fcb207e", size = 5405250, upload-time = "2026-03-06T13:48:09.628Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/9790c1655eabeb85b92b1ecab7d7e62a2069e53baefd58c98f0909c7a948/h5py-3.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:698dd69291272642ffda44a0ecd6cd3bda5faf9621452d255f57ce91487b9794", size = 5190108, upload-time = "2026-03-06T13:48:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/51/d7/ab693274f1bd7e8c5f9fdd6c7003a88d59bedeaf8752716a55f532924fbb/h5py-3.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b2c02b0a160faed5fb33f1ba8a264a37ee240b22e049ecc827345d0d9043074", size = 5419216, upload-time = "2026-03-06T13:48:13.322Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/0976b235cf29ead553e22f2fb6385a8252b533715e00d0ae52ed7b900582/h5py-3.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:96b422019a1c8975c2d5dadcf61d4ba6f01c31f92bbde6e4649607885fe502d6", size = 3182868, upload-time = "2026-03-06T13:48:15.759Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/866b7e570b39070f92d47b0ff1800f0f8239b6f9e45f02363d7112336c1f/h5py-3.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:39c2838fb1e8d97bcf1755e60ad1f3dd76a7b2a475928dc321672752678b96db", size = 2653286, upload-time = "2026-03-06T13:48:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9e/6142ebfda0cb6e9349c091eae73c2e01a770b7659255248d637bec54a88b/h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:370a845f432c2c9619db8eed334d1e610c6015796122b0e57aa46312c22617d9", size = 3671808, upload-time = "2026-03-06T13:48:19.737Z" }, + { url = "https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42108e93326c50c2810025aade9eac9d6827524cdccc7d4b75a546e5ab308edb", size = 3045837, upload-time = "2026-03-06T13:48:21.854Z" }, + { url = "https://files.pythonhosted.org/packages/da/1e/6172269e18cc5a484e2913ced33339aad588e02ba407fafd00d369e22ef3/h5py-3.16.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:099f2525c9dcf28de366970a5fb34879aab20491589fa89ce2863a84218bb524", size = 5193860, upload-time = "2026-03-06T13:48:24.071Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9300ad32dea9dfc5171f94d5f6948e159ed93e4701280b0f508773b3f582f402", size = 5400417, upload-time = "2026-03-06T13:48:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/bc/81/5b62d760039eed64348c98129d17061fdfc7839fc9c04eaaad6dee1004e4/h5py-3.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:171038f23bccddfc23f344cadabdfc9917ff554db6a0d417180d2747fe4c75a7", size = 5185214, upload-time = "2026-03-06T13:48:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/28/c4/532123bcd9080e250696779c927f2cb906c8bf3447df98f5ceb8dcded539/h5py-3.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e420b539fb6023a259a1b14d4c9f6df8cf50d7268f48e161169987a57b737ff", size = 5414598, upload-time = "2026-03-06T13:48:29.49Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/a27997f84341fc0dfcdd1fe4179b6ba6c32a7aa880fdb8c514d4dad6fba3/h5py-3.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:18f2bbcd545e6991412253b98727374c356d67caa920e68dc79eab36bf5fedad", size = 3175509, upload-time = "2026-03-06T13:48:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/bb8647521d4fd770c30a76cfc6cb6a2f5495868904054e92f2394c5a78ff/h5py-3.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:656f00e4d903199a1d58df06b711cf3ca632b874b4207b7dbec86185b5c8c7d4", size = 2647362, upload-time = "2026-03-06T13:48:33.411Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/7fcd9b4c9eed82e91fb15568992561019ae7a829d1f696b2c844355d95dd/h5py-3.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9c9d307c0ef862d1cd5714f72ecfafe0a5d7529c44845afa8de9f46e5ba8bd65", size = 3678608, upload-time = "2026-03-06T13:48:35.183Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b7/9366ed44ced9b7ef357ab48c94205280276db9d7f064aa3012a97227e966/h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c1eff849cdd53cbc73c214c30ebdb6f1bb8b64790b4b4fc36acdb5e43570210", size = 3054773, upload-time = "2026-03-06T13:48:37.139Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/4964bc0e91e86340c2bbda83420225b2f770dcf1eb8a39464871ad769436/h5py-3.16.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2c04d129f180019e216ee5f9c40b78a418634091c8782e1f723a6ca3658b965", size = 5198886, upload-time = "2026-03-06T13:48:38.879Z" }, + { url = "https://files.pythonhosted.org/packages/f1/16/d905e7f53e661ce2c24686c38048d8e2b750ffc4350009d41c4e6c6c9826/h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4360f15875a532bc7b98196c7592ed4fc92672a57c0a621355961cafb17a6dd", size = 5404883, upload-time = "2026-03-06T13:48:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/58f34cb74af46d39f4cd18ea20909a8514960c5a3e5b92fd06a28161e0a8/h5py-3.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3fae9197390c325e62e0a1aa977f2f62d994aa87aab182abbea85479b791197c", size = 5192039, upload-time = "2026-03-06T13:48:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ca/934a39c24ce2e2db017268c08da0537c20fa0be7e1549be3e977313fc8f5/h5py-3.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43259303989ac8adacc9986695b31e35dba6fd1e297ff9c6a04b7da5542139cc", size = 5421526, upload-time = "2026-03-06T13:48:44.838Z" }, + { url = "https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:fa48993a0b799737ba7fd21e2350fa0a60701e58180fae9f2de834bc39a147ab", size = 3183263, upload-time = "2026-03-06T13:48:47.117Z" }, + { url = "https://files.pythonhosted.org/packages/7b/48/a6faef5ed632cae0c65ac6b214a6614a0b510c3183532c521bdb0055e117/h5py-3.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:1897a771a7f40d05c262fc8f37376ec37873218544b70216872876c627640f63", size = 2663450, upload-time = "2026-03-06T13:48:48.707Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/0c8bb8aedb62c772cf7c1d427c7d1951477e8c2835f872bc0a13d1f85f86/h5py-3.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15922e485844f77c0b9d275396d435db3baa58292a9c2176a386e072e0cf2491", size = 3760693, upload-time = "2026-03-06T13:48:50.453Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1f/fcc5977d32d6387c5c9a694afee716a5e20658ac08b3ff24fdec79fb05f2/h5py-3.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:df02dd29bd247f98674634dfe41f89fd7c16ba3d7de8695ec958f58404a4e618", size = 3181305, upload-time = "2026-03-06T13:48:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/f5/a1/af87f64b9f986889884243643621ebbd4ac72472ba8ec8cec891ac8e2ca1/h5py-3.16.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0f456f556e4e2cebeebd9d66adf8dc321770a42593494a0b6f0af54a7567b242", size = 5074061, upload-time = "2026-03-06T13:48:54.089Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d0/146f5eaff3dc246a9c7f6e5e4f42bd45cc613bce16693bcd4d1f7c958bf5/h5py-3.16.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3e6cb3387c756de6a9492d601553dffea3fe11b5f22b443aac708c69f3f55e16", size = 5279216, upload-time = "2026-03-06T13:48:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/12a13424f1e604fc7df9497b73c0356fb78c2fb206abd7465ce47226e8fd/h5py-3.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8389e13a1fd745ad2856873e8187fd10268b2d9677877bb667b41aebd771d8b7", size = 5070068, upload-time = "2026-03-06T13:48:59.169Z" }, + { url = "https://files.pythonhosted.org/packages/41/8c/bbe98f813722b4873818a8db3e15aa3e625b59278566905ac439725e8070/h5py-3.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:346df559a0f7dcb31cf8e44805319e2ab24b8957c45e7708ce503b2ec79ba725", size = 5300253, upload-time = "2026-03-06T13:49:02.033Z" }, + { url = "https://files.pythonhosted.org/packages/32/9e/87e6705b4d6890e7cecdf876e2a7d3e40654a2ae37482d79a6f1b87f7b92/h5py-3.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4c6ab014ab704b4feaa719ae783b86522ed0bf1f82184704ed3c9e4e3228796e", size = 3381671, upload-time = "2026-03-06T13:49:04.351Z" }, + { url = "https://files.pythonhosted.org/packages/96/91/9fad90cfc5f9b2489c7c26ad897157bce82f0e9534a986a221b99760b23b/h5py-3.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:faca8fb4e4319c09d83337adc80b2ca7d5c5a343c2d6f1b6388f32cfecca13c1", size = 2740706, upload-time = "2026-03-06T13:49:06.347Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -275,6 +429,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -407,6 +604,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "locket" version = "1.0.0" @@ -416,6 +625,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -490,6 +716,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "memray" +version = "1.19.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "rich" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/04/5b886a36df947599e0f37cd46e6e44e565299815f044e2303ab2ae9f8870/memray-1.19.3.tar.gz", hash = "sha256:4e0fb29ff0a50c0ec9dc84294d8f2c83419feba561a37628b304c2ae4fe73d03", size = 2417089, upload-time = "2026-04-08T18:49:32.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/42/20f595239918edb3fd967f47df7b89d1b728c1d04f34c616b78f17e85305/memray-1.19.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:7919d48b8e6df45a1b332fae484a14d427619c123a254ca23468ec232bb4152b", size = 2183379, upload-time = "2026-04-08T18:47:52.846Z" }, + { url = "https://files.pythonhosted.org/packages/53/e7/8371761d845e126bbd3230e0137fc147db5f6c35cd6b6150b86ed80fe34b/memray-1.19.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d5b1484dd32752dd9a194571dbfe8de175ca7159f4b0a24efc7e954710ea0b0", size = 2161168, upload-time = "2026-04-08T18:47:54.614Z" }, + { url = "https://files.pythonhosted.org/packages/cf/15/5f87e8a2f73c6e7e79c48efbc9d5dd570ffd74754412115b6cd32c43f138/memray-1.19.3-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c3e9d2c0172ff0419832a510ac6305504712bf268059cdfd6b4e948f6ba1ccf5", size = 9789479, upload-time = "2026-04-08T18:47:56.578Z" }, + { url = "https://files.pythonhosted.org/packages/76/09/32caf5915b487a705fb7c41bfca5dfe65486995615143493e6b43773da8d/memray-1.19.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:96de36ae809eaf8484bdbd937ce890b3b206269f66a7e1dea004fd115a712d24", size = 10035022, upload-time = "2026-04-08T18:47:58.739Z" }, + { url = "https://files.pythonhosted.org/packages/64/2a/e01de8c939018dd99dc61627c7a706f25bec6f1665f071a8482e888b3d76/memray-1.19.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bf98ffa7b022ddbac8b832a2c4e63e2a7f67d0c69d38b1712bc3347e7066b", size = 9414903, upload-time = "2026-04-08T18:48:00.888Z" }, + { url = "https://files.pythonhosted.org/packages/38/09/ca69127bba2ecd5d2a9b62de06846c91d46dacb814d329deaf09e601dd7f/memray-1.19.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b49362844937831dc0f324fbad28cfd1cd7006a1e8dc1e950891a3b9cc02dcc4", size = 12252069, upload-time = "2026-04-08T18:48:03.563Z" }, + { url = "https://files.pythonhosted.org/packages/fa/79/095c09f62f2ca527e4b5e71c38e7c9686f06c15a39f69f691428c45bae83/memray-1.19.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:dcbfa513332366544a2fdc74f0e385f3a11a2233ca9de5aac986e396ce0a2293", size = 2185040, upload-time = "2026-04-08T18:48:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/2e/26/8328966c9b1cecbf43e8c96a210178f68fb1df8a62dd4f1735158aa6610b/memray-1.19.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4df9b00262fe48485c2afc4a52c88640d729d3bf999b3c633052d40c0ee970d1", size = 2163887, upload-time = "2026-04-08T18:48:07.455Z" }, + { url = "https://files.pythonhosted.org/packages/1a/eb/6ed7c46c2e6aec4d18926662114e88ba557f2e88c50fd5737b7989d67f3c/memray-1.19.3-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4a710509a5728a6f3b79eca615b538f9fa20452b21c0ba5265bdfb5fd4ff5f1d", size = 9743964, upload-time = "2026-04-08T18:48:09.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/668ad1dd30744564ef2d3e444bf39e10c41f1820240e949d0a3fadea9101/memray-1.19.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:578349f78268d2561275c4fb78c7ebe215f5264ad408d46207795ae58ec5d79e", size = 10013567, upload-time = "2026-04-08T18:48:11.513Z" }, + { url = "https://files.pythonhosted.org/packages/7a/aa/e5e969d50e57ef3ae41c6996626381afe3c9933d1924844d326f05240596/memray-1.19.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbbe64058ea329e7ff4f2c8067e6400b7db7d44a3f897a36c21f1a54e9b0c1ee", size = 9387646, upload-time = "2026-04-08T18:48:13.803Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ea/b23b30e31f365687004df304b9e46f19de4f5b860e78b363dd5d51f618f9/memray-1.19.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1a60d44b18ac5c1f6dde15e5d5f2dc75b5dfa55f450b5b682bbb1274564bcb41", size = 12237978, upload-time = "2026-04-08T18:48:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/e5637a682cb0af3b9e79017b98b8db5f4bf65c24d9e9fea10dec2495ca13/memray-1.19.3-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:1c7474aa70145eb2be1479b5465022d41a01467dbe4f8487d4fb4c3d38fd6ba9", size = 2184361, upload-time = "2026-04-08T18:48:18.775Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/fcd190a9de788363856d475b595b8a831fefc5c0ee282d27e849dda57d1c/memray-1.19.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ee7f3f0e3f7ee4f5c82692f886ffbabddc885bc9ab3a507732a1aa2155e16ed1", size = 2162568, upload-time = "2026-04-08T18:48:20.691Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/72d70d93f911974652e19f50024142ca674cb19d4f34a91cb2b2b3e065af/memray-1.19.3-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c1842b2a0033ca44d11c40c9afd5b6a8a29f00e53ea151da4bb81f6dfd194b79", size = 9743474, upload-time = "2026-04-08T18:48:22.544Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ed/d2390f02d978e31666f2821574940f553f27fd1cf38003b592038441a9b8/memray-1.19.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51bea540d8a79c83720bc74d5f9508911572dd77b5fb0fc46d2fe287d96a2dc7", size = 10012845, upload-time = "2026-04-08T18:48:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/34/0f/78eb8d0973e11d0780fabbd048cab184bd4f28788f211d6d7eb97aed7d04/memray-1.19.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:502b07e65a972c3d47e39b0c9f706189f8b667953f68f80672f0537d2ffd9633", size = 9391464, upload-time = "2026-04-08T18:48:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b7/e209d3fa09f096d7d4b7dd3be7948cc36879aa1ef75a349cf88a7ad74d00/memray-1.19.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e9dc493402446ff495edaa90c6b587a32ace14e2d4a022051458b535a029cada", size = 12244789, upload-time = "2026-04-08T18:48:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/36ae02ef5c55cc9d9febb89fb39b78135eaf76d71458703d91cde401142f/memray-1.19.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f62ca641421c0856b38b95c2bc39bdd1493330a94d526efd817d2135a2c1600b", size = 2186101, upload-time = "2026-04-08T18:48:32.663Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/b08b27e84fff1ea039508baaac252c3facbd3a81930e1a8108fd3db1dfcb/memray-1.19.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4335e51dac85891434de4de603f58be1296c2fa463dbf4075b7230820c2a58b", size = 2164551, upload-time = "2026-04-08T18:48:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/34/48/3a0422d71f43a361782517f6c0b93bdc32e4b211aaa7290c3038a91f991b/memray-1.19.3-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:03f0e1c3584e19951471413f858b70ecb4f1b5d2e3e4bf0c147c104ac79a8548", size = 9743615, upload-time = "2026-04-08T18:48:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/4b/52/f9ee7911819085e174fe07d1bab27691dc1486be39bded35baebda792069/memray-1.19.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:90e634b79be3c332d0d0ab3e1da6ccce6acd20c5fa847f505230ff49d9450a62", size = 9994310, upload-time = "2026-04-08T18:48:39.588Z" }, + { url = "https://files.pythonhosted.org/packages/aa/64/a1f75b5a105cc325b7719a4ed1690c45e751098d17a45902668fd9e0af67/memray-1.19.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4be4535a98c2c93c3195a769c342da12564c8bfd4f5056c3527d237845fd354", size = 9386965, upload-time = "2026-04-08T18:48:41.849Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/01178b4c2f61b1b64995f0de9ada899f91abe4de67db793df7d428834456/memray-1.19.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e3c6433d246063cc33d1ae25104120643492f0750ed5f7ac660d92aba064da6", size = 12238366, upload-time = "2026-04-08T18:48:44.14Z" }, + { url = "https://files.pythonhosted.org/packages/e8/21/8034941ba5aeb9f73e05becc40661af54d548c2047e4058948a3d94acd5d/memray-1.19.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e034252330961dadfe83ac0f111c9a1d3d8fb8f1358c9b992ed664b6a8b2d2a9", size = 2202163, upload-time = "2026-04-08T18:48:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/c2/62/ba34b1cf5a8c89f6f4b24e8471a6e6e011e50a86a0d697407320eeaf5b65/memray-1.19.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6ee43e6132fd457d0865b6a1d0de3acdb220e897625a32a0d609beaebb8abd20", size = 2181584, upload-time = "2026-04-08T18:48:48.516Z" }, + { url = "https://files.pythonhosted.org/packages/89/f7/901e83b6bf71d968bcfdcd5b081d8c5940ec7b5b9d28d67d5d4027d72529/memray-1.19.3-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:78eacb4e1c1b66cf7c4dd6dd27b0c88fb4598b63f3096e014d0a2b91ab70fb6f", size = 9706760, upload-time = "2026-04-08T18:48:50.161Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/a556d0e93d69786edd5c62b8d71bc04f8454b7655b23351a3d95a6d0bc62/memray-1.19.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c33442360bba89fd34c4b985cd505abbf2bbec6a40bdfdf50e1981a4afd9625", size = 9958651, upload-time = "2026-04-08T18:48:52.88Z" }, + { url = "https://files.pythonhosted.org/packages/0a/20/f9fea6a3bb8cbc835567ddf89eb524c6fb4c01130b9a4a2b88e65d0c7aca/memray-1.19.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63868a883a6ca926ed1e9200a0d8f3bedde6e1af2a8d4b6ef0cf85160a3b28cd", size = 9382102, upload-time = "2026-04-08T18:48:55.787Z" }, + { url = "https://files.pythonhosted.org/packages/41/4a/035e5bdeddbd51a2ba1be53f1fc979268c8ce51640b042db4cbee305849b/memray-1.19.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:96ee0a76e4ca57e2ad48a8eccc28531503029884a791bb3556cc9ddac74e148d", size = 12162201, upload-time = "2026-04-08T18:48:57.952Z" }, +] + [[package]] name = "msgpack" version = "1.2.1" @@ -613,12 +903,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "numcodecs" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/8a391e7c356366224734efd24da929cc4796fff468bfb179fe1af6548535/numcodecs-0.16.5.tar.gz", hash = "sha256:0d0fb60852f84c0bd9543cc4d2ab9eefd37fc8efcc410acd4777e62a1d300318", size = 6276387, upload-time = "2025-11-21T02:49:48.986Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/85/1ac101a40ead81eaa1c7dc49a8827a30e2e436211b43ebdc63c590eb1347/numcodecs-0.16.5-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:78382dcea50622f2ef1e6e7a71dbe7f861d8fe376b27b7c297c26907304fef1e", size = 1621795, upload-time = "2025-11-21T02:49:17.418Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cc/0d97ef55dda48cb0f93d7b92d761208e7a99bd2eea6b0e859426e6a99a21/numcodecs-0.16.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2d04a19cb57a3c519b4127ac377cca6471aee1990d7c18f5b1e3a4fe1306689", size = 1153030, upload-time = "2025-11-21T02:49:19.089Z" }, + { url = "https://files.pythonhosted.org/packages/5e/41/e120ee1b390730ac5987cde2afd82e2b8442cec315ab40b94b0373e93e73/numcodecs-0.16.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c043af648eb280cd61785c99c22ff5c3c3460f906eb51a8511327c4f5111b283", size = 8510503, upload-time = "2025-11-21T02:49:20.324Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/195ac84cc8f6077b4f0f421e8daee21b7f1bd88cb7716414234379fe68ec/numcodecs-0.16.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c398919ef2eb0e56b8e97456f622640bfd3deed06de3acc976989cbcb22628a3", size = 9123428, upload-time = "2025-11-21T02:49:22.328Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5b/af02c417954f46e5c7bd5163ac251f535877d909fce54861c99ae197f6f6/numcodecs-0.16.5-cp311-cp311-win_amd64.whl", hash = "sha256:3820860ed302d4d84a1c66e70981ff959d5eb712555be4e7d8ced49888594773", size = 801542, upload-time = "2025-11-21T02:49:24.265Z" }, + { url = "https://files.pythonhosted.org/packages/75/cc/55420f3641a67f78392dc0bc5d02cb9eb0a9dcebf2848d1ac77253ca61fa/numcodecs-0.16.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:24e675dc8d1550cd976a99479b87d872cb142632c75cc402fea04c08c4898523", size = 1656287, upload-time = "2025-11-21T02:49:25.755Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6c/86644987505dcb90ba6d627d6989c27bafb0699f9fd00187e06d05ea8594/numcodecs-0.16.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:94ddfa4341d1a3ab99989d13b01b5134abb687d3dab2ead54b450aefe4ad5bd6", size = 1148899, upload-time = "2025-11-21T02:49:26.87Z" }, + { url = "https://files.pythonhosted.org/packages/97/1e/98aaddf272552d9fef1f0296a9939d1487914a239e98678f6b20f8b0a5c8/numcodecs-0.16.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b554ab9ecf69de7ca2b6b5e8bc696bd9747559cb4dd5127bd08d7a28bec59c3a", size = 8534814, upload-time = "2025-11-21T02:49:28.547Z" }, + { url = "https://files.pythonhosted.org/packages/fb/53/78c98ef5c8b2b784453487f3e4d6c017b20747c58b470393e230c78d18e8/numcodecs-0.16.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad1a379a45bd3491deab8ae6548313946744f868c21d5340116977ea3be5b1d6", size = 9173471, upload-time = "2025-11-21T02:49:30.444Z" }, + { url = "https://files.pythonhosted.org/packages/1c/20/2fdec87fc7f8cec950d2b0bea603c12dc9f05b4966dc5924ba5a36a61bf6/numcodecs-0.16.5-cp312-cp312-win_amd64.whl", hash = "sha256:845a9857886ffe4a3172ba1c537ae5bcc01e65068c31cf1fce1a844bd1da050f", size = 801412, upload-time = "2025-11-21T02:49:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/38/38/071ced5a5fd1c85ba0e14ba721b66b053823e5176298c2f707e50bed11d9/numcodecs-0.16.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25be3a516ab677dad890760d357cfe081a371d9c0a2e9a204562318ac5969de3", size = 1654359, upload-time = "2025-11-21T02:49:33.673Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/5f84ba7525577c1b9909fc2d06ef11314825fc4ad4378f61d0e4c9883b4a/numcodecs-0.16.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0107e839ef75b854e969cb577e140b1aadb9847893937636582d23a2a4c6ce50", size = 1144237, upload-time = "2025-11-21T02:49:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/0b/00/787ea5f237b8ea7bc67140c99155f9c00b5baf11c49afc5f3bfefa298f95/numcodecs-0.16.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:015a7c859ecc2a06e2a548f64008c0ec3aaecabc26456c2c62f4278d8fc20597", size = 8483064, upload-time = "2025-11-21T02:49:36.454Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e6/d359fdd37498e74d26a167f7a51e54542e642ea47181eb4e643a69a066c3/numcodecs-0.16.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84230b4b9dad2392f2a84242bd6e3e659ac137b5a1ce3571d6965fca673e0903", size = 9126063, upload-time = "2025-11-21T02:49:38.018Z" }, + { url = "https://files.pythonhosted.org/packages/27/72/6663cc0382ddbb866136c255c837bcb96cc7ce5e83562efec55e1b995941/numcodecs-0.16.5-cp313-cp313-win_amd64.whl", hash = "sha256:5088145502ad1ebf677ec47d00eb6f0fd600658217db3e0c070c321c85d6cf3d", size = 799275, upload-time = "2025-11-21T02:49:39.558Z" }, + { url = "https://files.pythonhosted.org/packages/3c/9e/38e7ca8184c958b51f45d56a4aeceb1134ecde2d8bd157efadc98502cc42/numcodecs-0.16.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b05647b8b769e6bc8016e9fd4843c823ce5c9f2337c089fb5c9c4da05e5275de", size = 1654721, upload-time = "2025-11-21T02:49:40.602Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/260fa42e7b2b08e6e00ad632f8dd620961a60a459426c26cea390f8c68d0/numcodecs-0.16.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3832bd1b5af8bb3e413076b7d93318c8e7d7b68935006b9fa36ca057d1725a8f", size = 1146887, upload-time = "2025-11-21T02:49:41.721Z" }, + { url = "https://files.pythonhosted.org/packages/4e/15/e2e1151b5a8b14a15dfd4bb4abccce7fff7580f39bc34092780088835f3a/numcodecs-0.16.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49f7b7d24f103187f53135bed28bb9f0ed6b2e14c604664726487bb6d7c882e1", size = 8476987, upload-time = "2025-11-21T02:49:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/16a57fc4d9fb0ba06c600408bd6634f2f1753c54a7a351c99c5e09b51ee2/numcodecs-0.16.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aec9736d81b70f337d89c4070ee3ffeff113f386fd789492fa152d26a15043e4", size = 9102377, upload-time = "2025-11-21T02:49:45.508Z" }, + { url = "https://files.pythonhosted.org/packages/31/a5/a0425af36c20d55a3ea884db4b4efca25a43bea9214ba69ca7932dd997b4/numcodecs-0.16.5-cp314-cp314-win_amd64.whl", hash = "sha256:b16a14303800e9fb88abc39463ab4706c037647ac17e49e297faa5f7d7dbbf1d", size = 819022, upload-time = "2025-11-21T02:49:47.39Z" }, +] + [[package]] name = "numpy" version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.12'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ @@ -700,8 +1025,15 @@ name = "numpy" version = "2.5.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } wheels = [ @@ -759,6 +1091,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +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/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { 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]] name = "partd" version = "1.4.2" @@ -781,6 +1174,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -827,6 +1229,125 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -969,6 +1490,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1024,6 +1566,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "ruff" version = "0.15.20" @@ -1049,6 +1604,153 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +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 = "sortedcontainers" version = "2.4.0" @@ -1080,6 +1782,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl", hash = "sha256:26bdccf339bcce6a88b2b5432c988b266ebbe63a4e593f6b578b1d2e723d2b76", size = 12893, upload-time = "2025-11-12T12:21:14.407Z" }, ] +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + [[package]] name = "toolz" version = "1.1.0" @@ -1136,6 +1855,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "uvicorn" version = "0.49.0" @@ -1149,6 +1886,283 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ] +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { 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 = "xarray" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/a6/6fe936a798a3a38a79c7422d1a31afd2e9a14690fcb0ccff96bc01f04bf2/xarray-2026.4.0.tar.gz", hash = "sha256:c4ac9a01a945d90d5b1628e2af045099a9d4943536d4f2ee3ae963c3b222d15b", size = 3132311, upload-time = "2026-04-13T19:45:36.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-py3-none-any.whl", hash = "sha256:d43751d9fb4a90f9249c30431684f00c41bc874f1edccd862631a40cbc0edf08", size = 1414326, upload-time = "2026-04-13T19:45:34.659Z" }, +] + +[[package]] +name = "zarr" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "donfig", marker = "python_full_version < '3.12'" }, + { name = "google-crc32c", marker = "python_full_version < '3.12'" }, + { name = "numcodecs", marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/5a/b8a0cf39a14c770c30bd1f2d120c54000c8cd9e84e8e79f38d9a7ce58071/zarr-3.1.6.tar.gz", hash = "sha256:d95e72cbea4b90e9a70679468b8266400331756232576ae2b43400ac5108d0eb", size = 386531, upload-time = "2026-03-23T17:25:18.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/7c/ba8ca8cbe9dbef8e83a95fc208fed8e6686c98b4719aaa0aa7f3d31fe390/zarr-3.1.6-py3-none-any.whl", hash = "sha256:b5a82c5079d1c3d4ee8f06746fa3b9a98a7d804300fa3f4be154362a33e1207e", size = 295655, upload-time = "2026-03-23T17:25:17.189Z" }, +] + +[[package]] +name = "zarr" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "donfig", marker = "python_full_version >= '3.12'" }, + { name = "google-crc32c", marker = "python_full_version >= '3.12'" }, + { name = "numcodecs", marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/8d/aeb164004f87543b06ef54f885d02c342c31ceb274e2bbec470a98927621/zarr-3.2.1.tar.gz", hash = "sha256:71565b738a0e7e8ed226f0516eba8c6bb53440ad7669a8c48ebb3534a161d035", size = 675161, upload-time = "2026-05-05T12:37:22.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/0a/469e2bd01be1490336e6c8707386845655d59261543315778a3ccc7e8019/zarr-3.2.1-py3-none-any.whl", hash = "sha256:f78cdd3d9687ad0e9f9cba2c5683b64f0c52589c19f685eeabe872e93cc0d2c7", size = 319617, upload-time = "2026-05-05T12:37:20.66Z" }, +] + [[package]] name = "zict" version = "3.0.0" From 17da4111b790d803635bfc427d1d1b610111f85b Mon Sep 17 00:00:00 2001 From: polymood <36277904+polymood@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:49:42 +0200 Subject: [PATCH 2/4] feat: real-time dashboard rebuild Next.js dashboard streaming over WebSocket, in Dask's warm palette. - Live store (WebSocket + REST seed) with a collapsible runs sidebar that updates in real time; activity-based live indicator. - Workers table, global + per-worker Task stream (canvas, zoom/pan/box-zoom), whole-graph canvas DAG with deep zoom + edge highlighting. - Memory: canvas memory-over-time with click-to-inspect spike explorer, per-layer allocations-over-time, a real per-worker icicle flamegraph (memray tree read), and the peak-by-line / peak-by-task tables. - Dedicated Timeline page, in-app delete modal, SVG logo + favicon. --- web/app/globals.css | 488 +++++++++++++++++++++++++++- web/app/icon.svg | 29 ++ web/app/layout.tsx | 24 +- web/app/runs/[id]/layout.tsx | 125 ++++++- web/app/runs/[id]/memory/page.tsx | 12 + web/app/runs/[id]/page.tsx | 48 ++- web/app/runs/[id]/stream/page.tsx | 12 + web/app/runs/[id]/timeline/page.tsx | 12 +- web/app/runs/[id]/workers/page.tsx | 12 + web/components/AlignedTimeline.tsx | 160 --------- web/components/AppShell.tsx | 79 +++++ web/components/Flamegraph.tsx | 242 ++++++++++++++ web/components/GraphCanvas.tsx | 350 ++++++++++++++++++++ web/components/LayerMemoryChart.tsx | 336 +++++++++++++++++++ web/components/MemoryChart.tsx | 423 ++++++++++++++++++++---- web/components/MemoryDeep.tsx | 114 +++++++ web/components/MemoryExplorer.tsx | 39 +++ web/components/PostMortem.tsx | 31 +- web/components/Sidebar.tsx | 33 +- web/components/SpikeInspector.tsx | 147 +++++++++ web/components/TaskGraph.tsx | 135 ++++++-- web/components/TaskStream.tsx | 327 +++++++++++++++++++ web/components/WorkersView.tsx | 87 +++++ web/lib/api.ts | 30 ++ web/lib/live.tsx | 192 +++++++++++ web/lib/types.ts | 66 ++++ web/public/logo.svg | 22 ++ 27 files changed, 3243 insertions(+), 332 deletions(-) create mode 100644 web/app/icon.svg create mode 100644 web/app/runs/[id]/memory/page.tsx create mode 100644 web/app/runs/[id]/stream/page.tsx create mode 100644 web/app/runs/[id]/workers/page.tsx delete mode 100644 web/components/AlignedTimeline.tsx create mode 100644 web/components/AppShell.tsx create mode 100644 web/components/Flamegraph.tsx create mode 100644 web/components/GraphCanvas.tsx create mode 100644 web/components/LayerMemoryChart.tsx create mode 100644 web/components/MemoryDeep.tsx create mode 100644 web/components/MemoryExplorer.tsx create mode 100644 web/components/SpikeInspector.tsx create mode 100644 web/components/TaskStream.tsx create mode 100644 web/components/WorkersView.tsx create mode 100644 web/lib/live.tsx create mode 100644 web/public/logo.svg diff --git a/web/app/globals.css b/web/app/globals.css index f830f8c..6a65e35 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -8,8 +8,8 @@ --text: #1b1d21; --muted: #62666d; --faint: #8a8f98; - --accent: #3b5bdb; - --accent-soft: #edf0fd; + --accent: #e8590c; /* Dask warm orange — interactive accent */ + --accent-soft: #fdefe4; --danger: #d43b3b; --danger-soft: #fbecec; --warn: #b06a00; @@ -66,12 +66,15 @@ a { background: var(--bg); } .topbar .brand { + display: flex; + align-items: center; + gap: 8px; font-weight: 650; font-size: 13.5px; letter-spacing: 0.02em; } -.topbar .brand span { - color: var(--accent); +.topbar .brand .brand-text span { + color: var(--accent); /* Dask warm accent on the "Genie" */ } .topbar .sep { width: 1px; @@ -104,11 +107,35 @@ a { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 0; + transition: grid-template-columns 0.14s ease; +} +.shell.collapsed { + grid-template-columns: 0 1fr; } .sidebar { border-right: 1px solid var(--border); background: var(--sidebar); overflow-y: auto; + overflow-x: hidden; +} +.shell.collapsed .sidebar { + border-right: none; +} +.rail-toggle { + font: inherit; + font-size: 14px; + line-height: 1; + width: 26px; + height: 24px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--muted); + cursor: pointer; + margin-right: 4px; +} +.rail-toggle:hover { + background: var(--panel-2); + color: var(--text); } .content { overflow-y: auto; @@ -188,6 +215,21 @@ a { font-size: 12px; color: var(--faint); } +.run-header .origin { + font-size: 11.5px; + color: var(--muted); + background: var(--panel-2); + border: 1px solid var(--border-soft); + padding: 1px 7px; +} +.r-origin { + font-size: 10.5px; + color: var(--faint); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} .run-header .spacer { flex: 1; } @@ -307,6 +349,60 @@ a { border-color: #e6b8b8; background: var(--danger-soft); } +.btn.danger.solid { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} +.btn.danger.solid:hover { + background: #b83232; + color: #fff; +} +.btn:disabled { + opacity: 0.55; + cursor: default; +} + +/* ---- modal ---- */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(16, 24, 40, 0.32); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} +.modal { + background: var(--panel); + border: 1px solid var(--border); + box-shadow: 0 12px 40px rgba(16, 24, 40, 0.22); + width: 420px; + max-width: calc(100vw - 32px); +} +.modal-head { + padding: 12px 16px; + font-weight: 620; + font-size: 14px; + border-bottom: 1px solid var(--border); + background: var(--panel-2); +} +.modal-body { + padding: 16px; + font-size: 13px; + color: var(--muted); + line-height: 1.6; +} +.modal-body .mono { + color: var(--text); +} +.modal-foot { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--border); +} /* ---- post-mortem ---- */ .death { @@ -592,3 +688,387 @@ table.data tbody tr:hover { height: 9px; flex: none; } + +/* ---- live indicator ---- */ +.livedot { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-family: var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.livedot .dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--faint); +} +.livedot .dot.on { + background: var(--ok); + box-shadow: 0 0 0 3px rgba(30, 122, 70, 0.14); +} + +/* ---- workers table ---- */ +td .dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; + background: var(--faint); +} +td .dot.busy { + background: var(--ok); +} +td .dot.idle { + background: var(--border); +} +.wbar, +.membar { + position: relative; + height: 16px; + background: var(--panel-2); + border: 1px solid var(--border-soft); + overflow: hidden; +} +.wbar > span, +.membar > span { + position: absolute; + left: 0; + top: 0; + bottom: 0; + background: var(--accent); +} +.wbar > em, +.membar > em { + position: absolute; + right: 5px; + top: 0; + line-height: 16px; + font-style: normal; + font-family: var(--mono); + font-size: 10.5px; + color: var(--text); +} +.num { + text-align: right; + font-variant-numeric: tabular-nums; +} +.srcline { + font-weight: 560; +} + +/* ---- task stream ---- */ +.tstream-wrap { + border: 1px solid var(--border); + background: var(--panel); + overflow-x: hidden; + overflow-y: auto; + max-height: 78vh; +} +.tstream-tools { + position: absolute; + top: 6px; + right: 8px; + z-index: 4; +} +.tstream-tools .btn { + font-size: 11px; + padding: 2px 8px; +} +.tstream-tip { + position: absolute; + pointer-events: none; + background: var(--panel); + border: 1px solid var(--border); + box-shadow: 0 4px 12px rgba(16, 24, 40, 0.1); + padding: 6px 9px; + z-index: 5; + min-width: 140px; +} +.small { + font-size: 11px; +} + +/* ---- graph canvas ---- */ +.graph-hint { + position: absolute; + left: 8px; + bottom: 6px; + pointer-events: none; +} +.gc-zoom { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 4px; + background: var(--panel); + border: 1px solid var(--border); + padding: 3px; +} +.gc-zoom .btn { + width: 24px; + height: 22px; + padding: 0; + text-align: center; + font-size: 14px; + line-height: 1; +} +.gc-scale { + font-size: 11px; + color: var(--muted); + padding: 0 4px; + min-width: 40px; + text-align: right; +} +.gc-marquee { + position: absolute; + border: 1px dashed var(--accent); + background: rgba(232, 89, 12, 0.1); + pointer-events: none; + z-index: 3; +} + +/* ---- flamegraph (icicle) ---- */ +.flame-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + gap: 10px; +} +.fg-select { + font: inherit; + font-size: 12px; + padding: 3px 6px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); +} +.flame-split { + display: flex; + gap: 12px; + align-items: flex-start; +} +.flame-canvas { + position: relative; + flex: 1; + min-width: 0; + border: 1px solid var(--border-soft); + background: var(--panel-2); + overflow: hidden; +} +.flame-node { + position: absolute; + display: flex; + align-items: center; + overflow: hidden; + border-right: 1px solid var(--bg); + border-bottom: 1px solid var(--bg); + padding: 0 5px; + font-size: 10.5px; + font-family: var(--mono); + color: #fff; + white-space: nowrap; + cursor: pointer; +} +.flame-node.lib { + opacity: 0.55; /* framework frames recede so your code stands out */ +} +.flame-node.sel { + outline: 2px solid var(--text); + outline-offset: -2px; + z-index: 2; +} +.flame-node span { + overflow: hidden; + text-overflow: ellipsis; +} +.flame-detail { + width: 300px; + flex: none; + border: 1px solid var(--border); + background: var(--panel); + padding: 12px; +} +.flame-detail .fd-fn { + font-weight: 620; + font-size: 13px; + margin-bottom: 4px; + word-break: break-all; +} +.flame-detail .kv { + flex-direction: row; + justify-content: space-between; + margin: 8px 0 0; +} +.flame-detail .kv b { + color: var(--warn); + font-family: var(--mono); +} + +/* ---- deep sites in post-mortem ---- */ +.dsites { + padding: 8px 14px 2px; +} +.dsites-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--faint); + margin-bottom: 5px; +} +.dsite { + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; + padding: 3px 0; +} +.dsite b { + color: var(--danger); + font-family: var(--mono); +} +.dsite .mono { + color: var(--muted); +} + +/* ---- memory chart (canvas) ---- */ +.mchart { + width: 100%; +} +.mchart-reset { + position: absolute; + top: 4px; + right: 6px; + font-size: 11px; + padding: 2px 8px; +} + +/* ---- memory + spike inspector ---- */ +.mem-split { + display: grid; + grid-template-columns: minmax(0, 1.9fr) minmax(280px, 1fr); + gap: 16px; + align-items: start; +} +@media (max-width: 1100px) { + .mem-split { + grid-template-columns: 1fr; + } +} +/* dedicated Timeline page: roomy chart + inspector */ +.mem-explorer { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 16px; + align-items: start; +} +@media (max-width: 1000px) { + .mem-explorer { + grid-template-columns: 1fr; + } +} +.mem-explorer-chart { + min-width: 0; +} +.mem-explorer-side { + position: sticky; + top: 8px; +} +.mem-chart { + min-width: 0; +} +.spike-empty { + font-size: 12.5px; + line-height: 1.6; +} +.spike { + display: flex; + flex-direction: column; + max-height: 460px; +} +.spike-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + background: var(--panel-2); + font-size: 12.5px; +} +.spike-head b { + color: var(--accent); + font-family: var(--mono); +} +.spike-body { + padding: 12px 14px; + overflow-y: auto; +} +.spike-sec-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--faint); + margin-bottom: 8px; +} +.spike-task { + border-left: 2px solid var(--border); + padding: 4px 0 8px 10px; + margin-bottom: 8px; +} +.spike-task .st-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.spike-task .st-key { + font-size: 12.5px; + font-weight: 600; +} +.spike-task .srcpath { + font-family: var(--mono); + font-size: 11px; + color: var(--accent); + margin: 4px 0 4px; +} +table.data.compact td { + padding: 5px 8px; + font-size: 11.5px; +} + +/* ---- overview hot line ---- */ +.hotline { + display: flex; + align-items: center; + gap: 12px; + border: 1px solid var(--border); + background: var(--panel-2); + padding: 9px 14px; + margin-bottom: 20px; + font-size: 12.5px; +} +.hotline .k { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--faint); +} +.hotline .v { + color: var(--text); +} +.hotline b { + color: var(--warn); + font-family: var(--mono); +} +.hotline .spacer { + flex: 1; +} diff --git a/web/app/icon.svg b/web/app/icon.svg new file mode 100644 index 0000000..86f316c --- /dev/null +++ b/web/app/icon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 01913b4..d18f3a3 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,9 +1,8 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; -import Link from "next/link"; import "./globals.css"; import "@xyflow/react/dist/style.css"; -import { Sidebar } from "@/components/Sidebar"; +import { AppShell } from "@/components/AppShell"; const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); @@ -15,26 +14,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( -
-
- - DaskGenie - -
- dask inspection -
- - - collector connected - -
-
- -
{children}
-
-
+ {children} ); diff --git a/web/app/runs/[id]/layout.tsx b/web/app/runs/[id]/layout.tsx index 5070023..c406cd1 100644 --- a/web/app/runs/[id]/layout.tsx +++ b/web/app/runs/[id]/layout.tsx @@ -2,34 +2,98 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { deleteRun, useDeaths, useRun } from "@/lib/api"; +import { useEffect, useState } from "react"; +import { deleteRun, useRun } from "@/lib/api"; +import { LiveProvider, useLive } from "@/lib/live"; -export default function RunLayout({ - children, - params, +const LIVE_WINDOW_MS = 8000; // no frame within this → the run is idle/finished + +function LiveDot() { + const { connected, lastFrameAt } = useLive(); + const [now, setNow] = useState(() => Date.now()); + // re-evaluate so a run flips to "idle" once its stream goes quiet + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 2000); + return () => clearInterval(t); + }, []); + const live = connected && lastFrameAt > 0 && now - lastFrameAt < LIVE_WINDOW_MS; + const label = live ? "live" : lastFrameAt > 0 ? "idle" : connected ? "waiting" : "offline"; + return ( + + + {label} + + ); +} + +function ConfirmDelete({ + name, + busy, + onCancel, + onConfirm, }: { - children: React.ReactNode; - params: { id: string }; + name: string; + busy: boolean; + onCancel: () => void; + onConfirm: () => void; }) { - const { id } = params; + // Close on Escape; a proper in-app modal instead of the browser confirm(). + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel(); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onCancel]); + + return ( +
+
e.stopPropagation()} role="dialog" aria-modal="true"> +
Delete run
+
+ Delete {name} and all of its samples, spans, deaths and + deep-memory data? This can't be undone. +
+
+ + +
+
+
+ ); +} + +function RunChrome({ id, children }: { id: string; children: React.ReactNode }) { const { data: run } = useRun(id); - const { data: deaths } = useDeaths(id); + const { deaths } = useLive(); const pathname = usePathname(); const router = useRouter(); const base = `/runs/${id}`; - const deathCount = deaths?.length ?? 0; + const deathCount = deaths.length; + const [confirmOpen, setConfirmOpen] = useState(false); + const [deleting, setDeleting] = useState(false); const tabs = [ { href: base, label: "Overview" }, - { href: `${base}/postmortem`, label: `Post-mortem${deathCount ? ` · ${deathCount}` : ""}` }, { href: `${base}/timeline`, label: "Timeline" }, - { href: `${base}/graph`, label: "Task graph" }, + { href: `${base}/workers`, label: "Workers" }, + { href: `${base}/stream`, label: "Task stream" }, + { href: `${base}/graph`, label: "Graph" }, + { href: `${base}/memory`, label: "Memory" }, + { href: `${base}/postmortem`, label: `Post-mortem${deathCount ? ` · ${deathCount}` : ""}` }, ]; - async function onDelete() { - if (!confirm(`Delete run "${run?.name ?? id}"?`)) return; - await deleteRun(id); - router.push("/"); + async function onConfirmDelete() { + setDeleting(true); + try { + await deleteRun(id); + router.push("/"); + } finally { + setDeleting(false); + setConfirmOpen(false); + } } return ( @@ -37,8 +101,15 @@ export default function RunLayout({

{run?.name ?? "…"}

{id} + {run?.origin || run?.origin_ip ? ( + + {run.origin || "?"} + {run.origin_ip ? ` · ${run.origin_ip}` : ""} + + ) : null} + -
@@ -50,6 +121,28 @@ export default function RunLayout({ ))}
{children}
+ {confirmOpen && ( + !deleting && setConfirmOpen(false)} + onConfirm={onConfirmDelete} + /> + )} ); } + +export default function RunLayout({ + children, + params, +}: { + children: React.ReactNode; + params: { id: string }; +}) { + return ( + + {children} + + ); +} diff --git a/web/app/runs/[id]/memory/page.tsx b/web/app/runs/[id]/memory/page.tsx new file mode 100644 index 0000000..55e9116 --- /dev/null +++ b/web/app/runs/[id]/memory/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { MemoryDeep } from "@/components/MemoryDeep"; + +export default function MemoryPage({ params }: { params: { id: string } }) { + return ( + <> +
Deep memory — memray, folded to your source lines
+ + + ); +} diff --git a/web/app/runs/[id]/page.tsx b/web/app/runs/[id]/page.tsx index 8d7c71b..d987184 100644 --- a/web/app/runs/[id]/page.tsx +++ b/web/app/runs/[id]/page.tsx @@ -1,7 +1,8 @@ "use client"; import Link from "next/link"; -import { useDeaths, useGraph, useLayerStats, useRun, useTimeline } from "@/lib/api"; +import { useAllocSites, useGraph, useLayerStats, useRun } from "@/lib/api"; +import { useLive } from "@/lib/live"; import { baseName, layerColorMap } from "@/lib/colors"; import { bytes } from "@/lib/format"; import { MemoryChart } from "@/components/MemoryChart"; @@ -10,17 +11,18 @@ import { useMemo } from "react"; export default function OverviewPage({ params }: { params: { id: string } }) { const { id } = params; const { data: run } = useRun(id); - const { data: samples } = useTimeline(id); - const { data: deaths } = useDeaths(id); + const { samples, deaths } = useLive(); const { data: graph } = useGraph(id); const { data: layerStats } = useLayerStats(id); + const { data: allocSites } = useAllocSites(id); const colorOf = useMemo(() => layerColorMap(), []); - const peak = Math.max(0, ...(samples ?? []).map((s) => s.rss_bytes)); - const oom = (deaths ?? []).filter((d) => d.suspected_oom && d.suspect_keys.length > 0); - const times = (samples ?? []).map((s) => s.timestamp); + const peak = Math.max(0, ...samples.map((s) => s.rss_bytes)); + const oom = deaths.filter((d) => d.suspected_oom && d.suspect_keys.length > 0); + const times = samples.map((s) => s.timestamp); const duration = times.length > 1 ? Math.max(...times) - Math.min(...times) : 0; const maxTotal = Math.max(1, ...(layerStats ?? []).map((l) => l.total_seconds)); + const hotLine = (allocSites ?? [])[0]; return ( <> @@ -52,15 +54,33 @@ export default function OverviewPage({ params }: { params: { id: string } }) { )} -
-
-
Memory over time
- {samples && samples.length > 0 ? ( - - ) : ( -
No memory samples yet.
- )} + {hotLine && ( +
+ Hottest allocation + + {hotLine.filename.split("/").slice(-1)[0]}:{hotLine.lineno} {hotLine.function} + + {bytes(hotLine.hwm_bytes)} + + + Deep memory → +
+ )} + +
+ Memory over time ·{" "} + + open the Timeline to zoom & inspect spikes → + +
+ {samples && samples.length > 0 ? ( + + ) : ( +
No memory samples yet.
+ )} + +
Tasks by layer{duration ? ` · ${duration.toFixed(1)}s total` : ""} diff --git a/web/app/runs/[id]/stream/page.tsx b/web/app/runs/[id]/stream/page.tsx new file mode 100644 index 0000000..051b115 --- /dev/null +++ b/web/app/runs/[id]/stream/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { TaskStream } from "@/components/TaskStream"; + +export default function StreamPage() { + return ( + <> +
Task stream · live
+ + + ); +} diff --git a/web/app/runs/[id]/timeline/page.tsx b/web/app/runs/[id]/timeline/page.tsx index 72ddd06..89b6543 100644 --- a/web/app/runs/[id]/timeline/page.tsx +++ b/web/app/runs/[id]/timeline/page.tsx @@ -1,12 +1,18 @@ "use client"; -import { AlignedTimeline } from "@/components/AlignedTimeline"; +import { MemoryExplorer } from "@/components/MemoryExplorer"; +import { LayerMemoryChart } from "@/components/LayerMemoryChart"; export default function TimelinePage({ params }: { params: { id: string } }) { return ( <> -
Memory & task stream — time flows top to bottom
- +
Worker memory over time
+ + +
+ Allocations by task layer over time +
+ ); } diff --git a/web/app/runs/[id]/workers/page.tsx b/web/app/runs/[id]/workers/page.tsx new file mode 100644 index 0000000..538a939 --- /dev/null +++ b/web/app/runs/[id]/workers/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { WorkersView } from "@/components/WorkersView"; + +export default function WorkersPage() { + return ( + <> +
Workers · live
+ + + ); +} diff --git a/web/components/AlignedTimeline.tsx b/web/components/AlignedTimeline.tsx deleted file mode 100644 index e467ac8..0000000 --- a/web/components/AlignedTimeline.tsx +++ /dev/null @@ -1,160 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { useSpans, useTimeline } from "@/lib/api"; -import { baseName, layerColorMap } from "@/lib/colors"; -import { bytes } from "@/lib/format"; -import type { Sample, TaskSpan } from "@/lib/types"; - -// Layout constants (px). Time runs top -> bottom; memory grows left -> right in -// its band; the task stream is worker lanes to the right — all on one time axis. -const PAD_T = 26; -const PAD_B = 18; -const TIME_LABEL_W = 46; -const MEM_X0 = TIME_LABEL_W + 6; -const MEM_W = 190; -const STREAM_X0 = MEM_X0 + MEM_W + 40; - -function useWidth() { - const ref = useRef(null); - const [w, setW] = useState(960); - useEffect(() => { - if (!ref.current) return; - const ro = new ResizeObserver(([e]) => setW(e.contentRect.width)); - ro.observe(ref.current); - return () => ro.disconnect(); - }, []); - return [ref, w] as const; -} - -function fmtTime(s: number): string { - return s < 1 ? `${(s * 1000).toFixed(0)}ms` : `${s.toFixed(s < 10 ? 1 : 0)}s`; -} - -export function AlignedTimeline({ runId }: { runId: string }) { - const { data: samples } = useTimeline(runId); - const { data: spans } = useSpans(runId); - const [wrapRef, width] = useWidth(); - const colorOf = useMemo(() => layerColorMap(), []); - - const model = useMemo(() => { - const S: Sample[] = samples ?? []; - const T: TaskSpan[] = spans ?? []; - const times = [ - ...S.map((s) => s.timestamp), - ...T.map((t) => t.start), - ...T.map((t) => t.end), - ]; - if (times.length === 0) return null; - const t0 = Math.min(...times); - const t1 = Math.max(...times); - const dur = Math.max(0.001, t1 - t0); - const workers = [...new Set([...S.map((s) => s.worker), ...T.map((t) => t.worker)])].sort(); - const peak = Math.max(1, ...S.map((s) => s.rss_bytes)); - const layers = [...new Set(T.map((t) => baseName(t.layer)))]; - return { S, T, t0, dur, workers, peak, layers }; - }, [samples, spans]); - - if (!model) - return
No timeline yet — samples and task spans will appear here.
; - - const { S, T, t0, dur, workers, peak, layers } = model; - const plotH = Math.min(2400, Math.max(460, dur * 44)); - const H = plotH + PAD_T + PAD_B; - const streamW = Math.max(120, width - STREAM_X0 - 8); - const laneW = streamW / Math.max(1, workers.length); - const y = (t: number) => PAD_T + ((t - t0) / dur) * plotH; - const memX = (rss: number) => MEM_X0 + (rss / peak) * MEM_W; - const laneIdx = new Map(workers.map((w, i) => [w, i])); - - // time gridlines - const ticks = 6; - const gridlines = Array.from({ length: ticks + 1 }, (_, i) => (i / ticks) * dur); - - return ( -
-
- {layers.slice(0, 12).map((l) => ( - - - {l} - - ))} -
-
- - {/* time gridlines + labels */} - {gridlines.map((t, i) => ( - - - - {fmtTime(t)} - - - ))} - - {/* column headers */} - - memory (RSS →) - - - tasks by worker - - - {/* memory: one polyline per worker, time down / rss right */} - {workers.map((w, wi) => { - const pts = S.filter((s) => s.worker === w) - .sort((a, b) => a.timestamp - b.timestamp) - .map((s) => `${memX(s.rss_bytes).toFixed(1)},${y(s.timestamp).toFixed(1)}`) - .join(" "); - if (!pts) return null; - const c = ["#3b5bdb", "#0b7285", "#c2410c", "#5c940d"][wi % 4]; - return ( - - ); - })} - - 0 - - - {bytes(peak)} - - - {/* task stream lanes */} - {workers.map((w) => { - const i = laneIdx.get(w)!; - const x = STREAM_X0 + i * laneW; - return ( - - ); - })} - {T.map((s, i) => { - const lane = laneIdx.get(s.worker) ?? 0; - const x = STREAM_X0 + lane * laneW + 1; - const yy = y(s.start); - const h = Math.max(1.2, y(s.end) - y(s.start)); - return ( - - - {baseName(s.layer)} · {((s.end - s.start) * 1000).toFixed(0)}ms - - - ); - })} - -
-
- {T.length} task spans across {workers.length} worker{workers.length !== 1 ? "s" : ""} · time - runs top → bottom -
-
- ); -} diff --git a/web/components/AppShell.tsx b/web/components/AppShell.tsx new file mode 100644 index 0000000..3e4490b --- /dev/null +++ b/web/components/AppShell.tsx @@ -0,0 +1,79 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Sidebar } from "@/components/Sidebar"; + +// Array cells stacked into a flamegraph silhouette — arrays + flamegraphs, in +// Dask's warm palette. Mirrors app/icon.svg. +const LOGO_COLS = [2, 4, 3, 5, 4]; +const LOGO_RAMP = ["#D6402E", "#E8552D", "#F2762E", "#FDA33E", "#FFC83D"]; +function Logo() { + const cells: React.ReactNode[] = []; + LOGO_COLS.forEach((h, c) => { + for (let r = 0; r < h; r++) { + cells.push( + , + ); + } + }); + return ( + + {cells} + + ); +} + +// Client shell so the runs sidebar can collapse (state persisted). Keeps the +// root layout a server component. +export function AppShell({ children }: { children: React.ReactNode }) { + const [collapsed, setCollapsed] = useState(false); + + useEffect(() => { + setCollapsed(localStorage.getItem("dg-sidebar-collapsed") === "1"); + }, []); + const toggle = () => { + setCollapsed((c) => { + const next = !c; + localStorage.setItem("dg-sidebar-collapsed", next ? "1" : "0"); + return next; + }); + }; + + return ( +
+
+ + + + + DaskGenie + + +
+ dask inspection +
+ + + collector connected + +
+
+ +
{children}
+
+
+ ); +} diff --git a/web/components/Flamegraph.tsx b/web/components/Flamegraph.tsx new file mode 100644 index 0000000..076cc1d --- /dev/null +++ b/web/components/Flamegraph.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useFlamegraph } from "@/lib/api"; +import { useLive } from "@/lib/live"; +import { useEffect } from "react"; +import { bytes } from "@/lib/format"; +import { layerColorMap } from "@/lib/colors"; +import type { FlameFrame, FlameStack } from "@/lib/types"; + +const ROW = 20; // px per stack depth + +interface FNode { + key: string; + frame: FlameFrame | null; + bytes: number; + children: Map; +} + +interface Cell { + node: FNode; + x: number; // 0..1 within the rendered root + w: number; + depth: number; +} + +function shortFile(path: string): string { + return path.split("/").slice(-1)[0] ?? path; +} +function isLib(path: string): boolean { + return /site-packages|dist-packages|\/(numpy|dask|distributed|pandas|xarray|zarr)\//.test(path); +} + +function buildTree(stacks: FlameStack[]): FNode { + const root: FNode = { key: "root", frame: null, bytes: 0, children: new Map() }; + for (const s of stacks) { + root.bytes += s.hwm_bytes; + let node = root; + for (const f of s.frames) { + const k = `${f.function}|${f.filename}|${f.lineno}`; + let c = node.children.get(k); + if (!c) { + c = { key: `${node.key}/${k}`, frame: f, bytes: 0, children: new Map() }; + node.children.set(k, c); + } + c.bytes += s.hwm_bytes; + node = c; + } + } + return root; +} + +function layout(rootNode: FNode): { cells: Cell[]; maxDepth: number } { + const cells: Cell[] = []; + let maxDepth = 0; + const place = (node: FNode, x: number, w: number, depth: number) => { + if (w < 0.0015) return; // too thin to see or interact with + cells.push({ node, x, w, depth }); + maxDepth = Math.max(maxDepth, depth); + const kids = [...node.children.values()].sort((a, b) => b.bytes - a.bytes); + let cx = x; + for (const c of kids) { + const cw = w * (c.bytes / node.bytes); + place(c, cx, cw, depth + 1); + cx += cw; + } + }; + place(rootNode, 0, 1, 0); + return { cells, maxDepth }; +} + +function findByKey(root: FNode, key: string): FNode | null { + if (root.key === key) return root; + for (const c of root.children.values()) { + const hit = findByKey(c, key); + if (hit) return hit; + } + return null; +} + +export function Flamegraph({ runId }: { runId: string }) { + const [worker, setWorker] = useState(null); + const [focusKey, setFocusKey] = useState("root"); + const [selected, setSelected] = useState(null); + const [hover, setHover] = useState<{ x: number; y: number; node: FNode } | null>(null); + const { data, mutate } = useFlamegraph(runId, worker); + const { deepNonce } = useLive(); + const colorOf = useMemo(() => layerColorMap(), []); + + useEffect(() => { + if (deepNonce > 0) mutate(); + }, [deepNonce, mutate]); + + const tree = useMemo(() => buildTree(data?.stacks ?? []), [data]); + const focus = useMemo(() => findByKey(tree, focusKey) ?? tree, [tree, focusKey]); + const { cells, maxDepth } = useMemo(() => layout(focus), [focus]); + + const total = tree.bytes || 1; + const workers = data?.workers ?? []; + const hasStacks = (data?.stacks?.length ?? 0) > 0; + + return ( +
+
+ + Allocation flamegraph {worker ? `· ${shortWorker(worker)}` : "· all workers"} · peak{" "} + {bytes(focus.bytes)} + +
+ {focusKey !== "root" && ( + + )} + +
+
+ + {!hasStacks && ( +
+ {data + ? worker + ? "No call-stack data for this worker." + : "No call-stack data yet — needs the memray engine (deep=True) and one epoch." + : "Loading…"} +
+ )} + +
+
setHover(null)} + > + {cells.map((c) => { + const f = c.node.frame; + const label = f ? `${f.function}` : "all"; + const lib = f ? isLib(f.filename) : false; + const widthPct = c.w * 100; + return ( +
{ + const r = e.currentTarget.parentElement!.getBoundingClientRect(); + setHover({ x: e.clientX - r.left, y: e.clientY - r.top, node: c.node }); + }} + onClick={() => { + setSelected(c.node); + if (c.node.children.size > 0) setFocusKey(c.node.key); + }} + > + {widthPct > 4 ? label : ""} +
+ ); + })} + {hover && ( +
+ {hover.node.frame ? ( + <> +
{hover.node.frame.function}
+
+ {shortFile(hover.node.frame.filename)}:{hover.node.frame.lineno} +
+ + ) : ( +
all allocations
+ )} +
+ {bytes(hover.node.bytes)} · {((hover.node.bytes / total) * 100).toFixed(1)}% +
+
+ )} +
+ + {selected?.frame && ( +
+
{selected.frame.function}
+
+ {selected.frame.filename}:{selected.frame.lineno} +
+
+ peak held + {bytes(selected.bytes)} +
+
+ share of total + {((selected.bytes / total) * 100).toFixed(1)}% +
+
+ in this library? + {isLib(selected.frame.filename) ? "yes (framework)" : "your code"} +
+ +
+ )} +
+
+ Each bar is a call frame; width is peak bytes held below it. Click to zoom, again for + detail — the memray tree read, on your own code. +
+
+ ); +} + +function shortWorker(w: string): string { + return w.replace(/^tcp:\/\//, ""); +} diff --git a/web/components/GraphCanvas.tsx b/web/components/GraphCanvas.tsx new file mode 100644 index 0000000..b9b5fb4 --- /dev/null +++ b/web/components/GraphCanvas.tsx @@ -0,0 +1,350 @@ +"use client"; + +import dagre from "@dagrejs/dagre"; +import { useEffect, useMemo, useRef, useState } from "react"; + +export interface CNode { + id: string; + label: string; + layer: string; + color: string; + hot: boolean; +} + +interface Placed { + id: string; + x: number; + y: number; + w: number; + h: number; + data: CNode; +} + +const NW = 26; +const NH = 14; +const MAX_SCALE = 40; // deep zoom for reading individual tasks +const MIN_SCALE = 0.04; +const LABEL_SCALE = 1.4; // show node labels once zoomed in past this + +// A canvas DAG for large task graphs — the real connected graph, not a +// layer-level summary. dagre gives a top-to-bottom layered layout; we draw it +// on a canvas with pan (drag) and zoom (wheel) so thousands of nodes stay +// responsive where a DOM/SVG renderer would choke. Click hit-tests to select. +export function GraphCanvas({ + nodes, + edges, + onSelect, + selected, +}: { + nodes: CNode[]; + edges: [string, string][]; + onSelect: (id: string) => void; + selected: string | null; +}) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const [size, setSize] = useState({ w: 900, h: 620 }); + const view = useRef({ scale: 1, tx: 0, ty: 0 }); + const drag = useRef<{ x: number; y: number; moved: boolean } | null>(null); + const marquee = useRef<{ x0: number; y0: number } | null>(null); + const [box, setBox] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + const [hover, setHover] = useState<{ x: number; y: number; node: CNode } | null>(null); + const [, force] = useState(0); + const redraw = () => force((n) => n + 1); + + const layout = useMemo(() => { + const g = new dagre.graphlib.Graph(); + g.setGraph({ rankdir: "TB", nodesep: 8, ranksep: 24 }); + g.setDefaultEdgeLabel(() => ({})); + const ids = new Set(nodes.map((n) => n.id)); + nodes.forEach((n) => g.setNode(n.id, { width: NW, height: NH })); + const drawnEdges: [string, string][] = []; + for (const [s, t] of edges) { + if (ids.has(s) && ids.has(t)) { + g.setEdge(s, t); + drawnEdges.push([s, t]); + } + } + dagre.layout(g); + const placed: Placed[] = nodes.map((n) => { + const p = g.node(n.id); + return { id: n.id, x: p.x, y: p.y, w: NW, h: NH, data: n }; + }); + const byId = new Map(placed.map((p) => [p.id, p])); + const gw = (g.graph().width ?? 1000) as number; + const gh = (g.graph().height ?? 1000) as number; + return { placed, byId, drawnEdges, gw, gh }; + }, [nodes, edges]); + + // Fit to view whenever the graph changes. + useEffect(() => { + const { gw, gh } = layout; + const scale = Math.min(size.w / (gw + 40), size.h / (gh + 40), 1.5); + view.current = { + scale, + tx: (size.w - gw * scale) / 2, + ty: 20, + }; + redraw(); + }, [layout, size]); + + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver(() => + setSize({ w: el.clientWidth, h: el.clientHeight }), + ); + ro.observe(el); + setSize({ w: el.clientWidth, h: el.clientHeight }); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + canvas.width = size.w * dpr; + canvas.height = size.h * dpr; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, size.w, size.h); + const { scale, tx, ty } = view.current; + ctx.save(); + ctx.translate(tx, ty); + ctx.scale(scale, scale); + + // edges + ctx.strokeStyle = "#c9ccd2"; + ctx.lineWidth = 0.5 / scale; + ctx.beginPath(); + for (const [s, t] of layout.drawnEdges) { + const a = layout.byId.get(s); + const b = layout.byId.get(t); + if (!a || !b) continue; + ctx.moveTo(a.x, a.y + a.h / 2); + ctx.lineTo(b.x, b.y - b.h / 2); + } + ctx.stroke(); + + // nodes — cull to the visible world rect so deep zoom stays fast + const showLabels = scale > LABEL_SCALE; + const vx0 = -tx / scale; + const vy0 = -ty / scale; + const vx1 = (size.w - tx) / scale; + const vy1 = (size.h - ty) / scale; + const fontPx = Math.min(9, Math.max(5, 8)); + for (const p of layout.placed) { + if (p.x < vx0 - p.w || p.x > vx1 + p.w || p.y < vy0 - p.h || p.y > vy1 + p.h) continue; + ctx.fillStyle = p.data.color; + ctx.fillRect(p.x - p.w / 2, p.y - p.h / 2, p.w, p.h); + const isSel = p.id === selected; + const isHover = hover?.node.id === p.id; + if (p.data.hot || isSel || isHover) { + ctx.strokeStyle = isSel ? "#1b1d21" : isHover ? "#e8590c" : "#d43b3b"; + ctx.lineWidth = (isSel || isHover ? 2.5 : 2) / scale; + ctx.strokeRect(p.x - p.w / 2, p.y - p.h / 2, p.w, p.h); + } + if (showLabels) { + ctx.fillStyle = "#1b1d21"; + ctx.font = `${fontPx}px ui-monospace, monospace`; + ctx.textBaseline = "middle"; + ctx.save(); + ctx.beginPath(); + ctx.rect(p.x - p.w / 2, p.y - p.h / 2, p.w, p.h); + ctx.clip(); + ctx.fillText(p.data.label, p.x - p.w / 2 + 2, p.y); + ctx.restore(); + } + } + + // highlight the active node's edges so its connections are unmistakable + const active = hover?.node.id ?? selected; + if (active) { + ctx.strokeStyle = "#e8590c"; + ctx.lineWidth = 1.6 / scale; + ctx.beginPath(); + for (const [s, t] of layout.drawnEdges) { + if (s !== active && t !== active) continue; + const a = layout.byId.get(s); + const b = layout.byId.get(t); + if (!a || !b) continue; + ctx.moveTo(a.x, a.y + a.h / 2); + ctx.lineTo(b.x, b.y - b.h / 2); + } + ctx.stroke(); + } + ctx.restore(); + }); + + const toWorld = (cx: number, cy: number) => { + const { scale, tx, ty } = view.current; + return { x: (cx - tx) / scale, y: (cy - ty) / scale }; + }; + + const onWheel = (e: React.WheelEvent) => { + e.preventDefault(); + const rect = canvasRef.current!.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + const before = toWorld(cx, cy); + // finer step for precise control; trackpads send small deltas → scale them + const step = Math.min(0.25, Math.abs(e.deltaY) / 400 + 0.06); + const factor = e.deltaY < 0 ? 1 + step : 1 / (1 + step); + const v = view.current; + v.scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, v.scale * factor)); + v.tx = cx - before.x * v.scale; + v.ty = cy - before.y * v.scale; + redraw(); + }; + + const nodeAt = (cx: number, cy: number): Placed | null => { + const w = toWorld(cx, cy); + for (const p of layout.placed) { + if (Math.abs(w.x - p.x) <= p.w / 2 + 1 && Math.abs(w.y - p.y) <= p.h / 2 + 1) return p; + } + return null; + }; + + const canvasXY = (e: React.MouseEvent) => { + const rect = canvasRef.current!.getBoundingClientRect(); + return { cx: e.clientX - rect.left, cy: e.clientY - rect.top }; + }; + + const onDown = (e: React.MouseEvent) => { + // Shift-drag draws a box to zoom into that region; plain drag pans. + if (e.shiftKey) { + const { cx, cy } = canvasXY(e); + marquee.current = { x0: cx, y0: cy }; + setBox({ x: cx, y: cy, w: 0, h: 0 }); + return; + } + drag.current = { x: e.clientX, y: e.clientY, moved: false }; + }; + const onMoveDrag = (e: React.MouseEvent) => { + if (marquee.current) { + const { cx, cy } = canvasXY(e); + const { x0, y0 } = marquee.current; + setBox({ x: Math.min(x0, cx), y: Math.min(y0, cy), w: Math.abs(cx - x0), h: Math.abs(cy - y0) }); + return; + } + if (drag.current) { + const dx = e.clientX - drag.current.x; + const dy = e.clientY - drag.current.y; + if (Math.abs(dx) + Math.abs(dy) > 2) drag.current.moved = true; + view.current.tx += dx; + view.current.ty += dy; + drag.current.x = e.clientX; + drag.current.y = e.clientY; + if (hover) setHover(null); + redraw(); + return; + } + // hover hit-test for the tooltip + highlight + const rect = canvasRef.current!.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + const p = nodeAt(cx, cy); + if (p) setHover({ x: cx, y: cy, node: p.data }); + else if (hover) setHover(null); + }; + const onUp = (e: React.MouseEvent) => { + if (marquee.current) { + marquee.current = null; + const m = box; + setBox(null); + if (m && m.w > 8 && m.h > 8) { + const w0 = toWorld(m.x, m.y); + const w1 = toWorld(m.x + m.w, m.y + m.h); + const worldW = Math.max(1, w1.x - w0.x); + const worldH = Math.max(1, w1.y - w0.y); + const scale = Math.max( + MIN_SCALE, + Math.min(MAX_SCALE, Math.min(size.w / worldW, size.h / worldH)), + ); + const cxw = (w0.x + w1.x) / 2; + const cyw = (w0.y + w1.y) / 2; + view.current = { scale, tx: size.w / 2 - cxw * scale, ty: size.h / 2 - cyw * scale }; + redraw(); + } + return; + } + const d = drag.current; + drag.current = null; + if (d && !d.moved) { + const { cx, cy } = canvasXY(e); + const p = nodeAt(cx, cy); + if (p) onSelect(p.id); + } + }; + + const zoomBy = (factor: number) => { + const v = view.current; + const cx = size.w / 2; + const cy = size.h / 2; + const before = toWorld(cx, cy); + v.scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, v.scale * factor)); + v.tx = cx - before.x * v.scale; + v.ty = cy - before.y * v.scale; + redraw(); + }; + const fit = () => { + const { gw, gh } = layout; + const scale = Math.min(size.w / (gw + 40), size.h / (gh + 40), 1.5); + view.current = { scale, tx: (size.w - gw * scale) / 2, ty: 20 }; + redraw(); + }; + + return ( +
+ { + drag.current = null; + marquee.current = null; + setBox(null); + setHover(null); + }} + /> + {box && ( +
+ )} +
+ + + + {Math.round(view.current.scale * 100)}% +
+ {hover && ( +
+
{hover.node.label}
+
{hover.node.layer}
+
+ )} +
+ scroll / +− to zoom · drag to pan · shift-drag to box-zoom · click a node +
+
+ ); +} diff --git a/web/components/LayerMemoryChart.tsx b/web/components/LayerMemoryChart.tsx new file mode 100644 index 0000000..2c18065 --- /dev/null +++ b/web/components/LayerMemoryChart.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useAllocTimeline } from "@/lib/api"; +import { useLive } from "@/lib/live"; +import { baseName, layerColorMap } from "@/lib/colors"; +import { bytes } from "@/lib/format"; + +const PAD_L = 66; +const PAD_R = 12; +const PAD_T = 10; +const AXIS_H = 20; +const HEIGHT = 360; +const MAX_LAYERS = 12; + +interface Bucket { + t: number; + vals: Record; +} + +// Deep-memory allocations over time, stacked by task layer — canvas so it has +// the same scroll-to-zoom / drag-to-pan / shift-box-zoom as the other charts. +export function LayerMemoryChart({ runId }: { runId: string }) { + const { data, mutate } = useAllocTimeline(runId); + const { deepNonce } = useLive(); + const colorOf = useMemo(() => layerColorMap(), []); + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const [width, setWidth] = useState(900); + const [hover, setHover] = useState<{ x: number; y: number; t: number } | null>(null); + + useEffect(() => { + if (deepNonce > 0) mutate(); + }, [deepNonce, mutate]); + + const { buckets, layers, t0, domainHi, yMax } = useMemo(() => { + const raw = (data ?? []).map((r) => ({ ...r, bytes: Number(r.bytes) })); + if (raw.length === 0) + return { buckets: [] as Bucket[], layers: [] as string[], t0: 0, domainHi: 1, yMax: 1 }; + const t0 = Math.min(...raw.map((r) => r.ts)); + const peak = new Map(); + for (const r of raw) peak.set(r.layer, Math.max(peak.get(r.layer) ?? 0, r.bytes)); + const layers = [...peak.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([l]) => l) + .slice(0, MAX_LAYERS); + const keep = new Set(layers); + const hasOther = raw.some((r) => !keep.has(r.layer)); + const byT = new Map>(); + for (const r of raw) { + const t = Math.round((r.ts - t0) * 2) / 2; + const row = byT.get(t) ?? {}; + const key = keep.has(r.layer) ? r.layer : "(other)"; + row[key] = (row[key] ?? 0) + r.bytes; + byT.set(t, row); + } + if (hasOther) layers.push("(other)"); + const buckets: Bucket[] = [...byT.entries()] + .map(([t, vals]) => ({ t, vals })) + .sort((a, b) => a.t - b.t); + let yMax = 1; + let domainHi = 1; + for (const b of buckets) { + const tot = layers.reduce((a, l) => a + (b.vals[l] ?? 0), 0); + if (tot > yMax) yMax = tot; + if (b.t > domainHi) domainHi = b.t; + } + return { buckets, layers, t0, domainHi, yMax: yMax * 1.05 }; + }, [data]); + + const view = useRef({ lo: 0, hi: domainHi }); + const [, force] = useState(0); + const redraw = () => force((n) => n + 1); + const drag = useRef<{ x: number; lo: number; hi: number } | null>(null); + const marquee = useRef<{ x0: number } | null>(null); + const [box, setBox] = useState<{ x: number; w: number } | null>(null); + + useEffect(() => { + view.current = { lo: 0, hi: domainHi }; + redraw(); + }, [domainHi]); + + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setWidth(el.clientWidth)); + ro.observe(el); + setWidth(el.clientWidth); + return () => ro.disconnect(); + }, []); + + const plotL = PAD_L; + const plotW = Math.max(1, width - PAD_L - PAD_R); + const plotT = PAD_T; + const plotH = Math.max(1, HEIGHT - PAD_T - AXIS_H); + const xOf = (t: number) => + plotL + ((t - view.current.lo) / (view.current.hi - view.current.lo)) * plotW; + const tOf = (x: number) => + view.current.lo + ((x - plotL) / plotW) * (view.current.hi - view.current.lo); + const yOf = (v: number) => plotT + (1 - v / yMax) * plotH; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = HEIGHT * dpr; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, HEIGHT); + const { lo, hi } = view.current; + + // y grid + ctx.strokeStyle = "#eef0f3"; + ctx.fillStyle = "#9298a2"; + ctx.font = "10px ui-monospace, monospace"; + ctx.textBaseline = "middle"; + for (let i = 0; i <= 4; i++) { + const v = (yMax * i) / 4; + const y = yOf(v); + ctx.beginPath(); + ctx.moveTo(plotL, y); + ctx.lineTo(width - PAD_R, y); + ctx.stroke(); + ctx.fillText(bytes(v), 6, y); + } + + const vis = buckets.filter((b) => b.t >= lo - 1 && b.t <= hi + 1); + if (vis.length >= 2) { + ctx.save(); + ctx.beginPath(); + ctx.rect(plotL, plotT, plotW, plotH); + ctx.clip(); + // stack from bottom: keep a running cumulative per bucket + const cum = new Array(vis.length).fill(0); + for (let li = layers.length - 1; li >= 0; li--) { + const layer = layers[li]; + ctx.beginPath(); + // top edge L->R + for (let i = 0; i < vis.length; i++) { + const top = cum[i] + (vis[i].vals[layer] ?? 0); + const x = xOf(vis[i].t); + const y = yOf(top); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + // bottom edge R->L + for (let i = vis.length - 1; i >= 0; i--) { + ctx.lineTo(xOf(vis[i].t), yOf(cum[i])); + } + ctx.closePath(); + ctx.fillStyle = colorOf(layer); + ctx.globalAlpha = 0.78; + ctx.fill(); + ctx.globalAlpha = 1; + for (let i = 0; i < vis.length; i++) cum[i] += vis[i].vals[layer] ?? 0; + } + // hover crosshair + if (hover) { + ctx.strokeStyle = "#8a8f98"; + ctx.beginPath(); + ctx.moveTo(hover.x, plotT); + ctx.lineTo(hover.x, plotT + plotH); + ctx.stroke(); + } + ctx.restore(); + } + + // x axis + ctx.fillStyle = "#8a8f98"; + ctx.textBaseline = "top"; + for (let i = 0; i <= 8; i++) { + const t = lo + ((hi - lo) * i) / 8; + ctx.fillText(`${t.toFixed(1)}s`, Math.min(xOf(t), width - 28), plotT + plotH + 4); + } + }); + + const canvasXY = (e: React.MouseEvent) => { + const r = canvasRef.current!.getBoundingClientRect(); + return { cx: e.clientX - r.left, cy: e.clientY - r.top }; + }; + const onWheel = (e: React.WheelEvent) => { + e.preventDefault(); + const { cx } = canvasXY(e); + if (cx < plotL) return; + const { lo, hi } = view.current; + const tc = tOf(cx); + const step = Math.min(0.25, Math.abs(e.deltaY) / 400 + 0.06); + const factor = e.deltaY < 0 ? 1 - step : 1 + step; + const nlo = Math.max(0, tc - (tc - lo) * factor); + const nhi = Math.min(domainHi, tc + (hi - tc) * factor); + if (nhi - nlo > 0.02) { + view.current = { lo: nlo, hi: nhi }; + redraw(); + } + }; + const onDown = (e: React.MouseEvent) => { + const { cx } = canvasXY(e); + if (cx < plotL) return; + if (e.shiftKey) { + marquee.current = { x0: cx }; + setBox({ x: cx, w: 0 }); + return; + } + drag.current = { x: cx, lo: view.current.lo, hi: view.current.hi }; + }; + const onMove = (e: React.MouseEvent) => { + const { cx, cy } = canvasXY(e); + if (marquee.current) { + setBox({ x: Math.min(marquee.current.x0, cx), w: Math.abs(cx - marquee.current.x0) }); + return; + } + if (drag.current) { + const { lo, hi } = drag.current; + const dt = ((cx - drag.current.x) / plotW) * (hi - lo); + let nlo = lo - dt; + let nhi = hi - dt; + const span = hi - lo; + if (nlo < 0) { + nlo = 0; + nhi = span; + } + if (nhi > domainHi) { + nhi = domainHi; + nlo = domainHi - span; + } + view.current = { lo: nlo, hi: nhi }; + setHover(null); + redraw(); + return; + } + if (cx >= plotL && cx <= width - PAD_R) setHover({ x: cx, y: cy, t: tOf(cx) }); + else setHover(null); + }; + const onUp = () => { + if (marquee.current) { + const m = box; + marquee.current = null; + setBox(null); + if (m && m.w > 6) { + const nlo = tOf(m.x); + const nhi = tOf(m.x + m.w); + if (nhi - nlo > 0.02) { + view.current = { lo: nlo, hi: nhi }; + redraw(); + } + } + return; + } + drag.current = null; + }; + const reset = () => { + view.current = { lo: 0, hi: domainHi }; + redraw(); + }; + + if (!data || buckets.length === 0) + return ( +
+ No per-layer allocation data yet — needs the memray engine (deep=True). +
+ ); + + const zoomed = view.current.lo > 1e-6 || view.current.hi < domainHi - 1e-6; + // nearest bucket for the hover tooltip + let nb: Bucket | null = null; + if (hover) for (const b of buckets) if (!nb || Math.abs(b.t - hover.t) < Math.abs(nb.t - hover.t)) nb = b; + const hoverRows = nb + ? layers + .map((l) => ({ l, v: nb!.vals[l] ?? 0 })) + .filter((r) => r.v > 0) + .sort((a, b) => b.v - a.v) + : []; + + return ( +
+
+ {layers.map((l) => ( + + + {baseName(l)} + + ))} + · scroll to zoom · drag to pan · shift-drag box-zoom +
+
+ { + drag.current = null; + marquee.current = null; + setBox(null); + setHover(null); + }} + /> + {box && ( +
+ )} + {zoomed && ( + + )} + {hover && nb && hoverRows.length > 0 && ( +
+
t = {nb.t.toFixed(1)}s
+ {hoverRows.slice(0, 10).map((r) => ( +
+ + {baseName(r.l)} + {bytes(r.v)} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/web/components/MemoryChart.tsx b/web/components/MemoryChart.tsx index 7b58571..b56fc5d 100644 --- a/web/components/MemoryChart.tsx +++ b/web/components/MemoryChart.tsx @@ -1,90 +1,369 @@ "use client"; -import { - CartesianGrid, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; +import { useEffect, useMemo, useRef, useState } from "react"; import { bytes } from "@/lib/format"; -import type { Sample } from "@/lib/types"; +import type { DeathEvent, Sample } from "@/lib/types"; const PALETTE = ["#4f46e5", "#0891b2", "#16a34a", "#d97706", "#db2777", "#7c3aed"]; +const PAD_L = 66; +const PAD_R = 12; +const PAD_T = 22; // room for the "death" labels +const AXIS_H = 20; -function toRows(samples: Sample[]) { - if (samples.length === 0) return { rows: [] as Record[], workers: [] as string[] }; - const t0 = Math.min(...samples.map((s) => s.timestamp)); - const workers = [...new Set(samples.map((s) => s.worker))]; - const byBucket = new Map>(); - for (const s of samples) { - const t = Math.round((s.timestamp - t0) * 4) / 4; - const row = byBucket.get(t) ?? { t }; - row[s.worker] = s.rss_bytes; - byBucket.set(t, row); - } - const rows = [...byBucket.values()].sort((a, b) => a.t - b.t); - return { rows, workers }; +function shortWorker(w: string) { + return w.replace(/^tcp:\/\//, ""); } -export function MemoryChart({ samples }: { samples: Sample[] }) { - const { rows, workers } = toRows(samples); - const peak = Math.max(1, ...samples.map((s) => s.rss_bytes)); +interface Series { + worker: string; + color: string; + pts: { t: number; rss: number }[]; +} + +// Memory over time on a canvas, with the same navigation as the graph / task +// stream: scroll to zoom the time axis, drag to pan, shift-drag to box-zoom. +// Click a point to pin an instant (the parent's spike inspector). +export function MemoryChart({ + samples, + deaths = [], + selectedTime = null, + onSelect, + height = 400, +}: { + samples: Sample[]; + deaths?: DeathEvent[]; + selectedTime?: number | null; + onSelect?: (absTime: number) => void; + height?: number; +}) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const [width, setWidth] = useState(900); + const [hover, setHover] = useState<{ x: number; y: number; t: number } | null>(null); + + const { t0, series, peak, domainHi } = useMemo(() => { + if (samples.length === 0) return { t0: 0, series: [] as Series[], peak: 1, domainHi: 1 }; + const t0 = Math.min(...samples.map((s) => s.timestamp)); + const byW = new Map(); + let peak = 1; + let domainHi = 1; + for (const s of samples) { + if (!s.worker) continue; + const t = s.timestamp - t0; + (byW.get(s.worker) ?? byW.set(s.worker, []).get(s.worker)!).push({ t, rss: s.rss_bytes }); + if (s.rss_bytes > peak) peak = s.rss_bytes; + if (t > domainHi) domainHi = t; + } + const series: Series[] = [...byW.entries()].map(([worker, pts], i) => ({ + worker, + color: PALETTE[i % PALETTE.length], + pts: pts.sort((a, b) => a.t - b.t), + })); + return { t0, series, peak, domainHi }; + }, [samples]); + + const view = useRef({ lo: 0, hi: domainHi }); + const [, force] = useState(0); + const redraw = () => force((n) => n + 1); + const drag = useRef<{ x: number; lo: number; hi: number; moved: boolean } | null>(null); + const marquee = useRef<{ x0: number } | null>(null); + const [box, setBox] = useState<{ x: number; w: number } | null>(null); + + useEffect(() => { + view.current = { lo: 0, hi: domainHi }; + redraw(); + }, [domainHi]); + + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setWidth(el.clientWidth)); + ro.observe(el); + setWidth(el.clientWidth); + return () => ro.disconnect(); + }, []); + + const plotL = PAD_L; + const plotW = Math.max(1, width - PAD_L - PAD_R); + const plotT = PAD_T; + const plotH = Math.max(1, height - PAD_T - AXIS_H); + const yMax = peak * 1.1; + const xOf = (t: number) => plotL + ((t - view.current.lo) / (view.current.hi - view.current.lo)) * plotW; + const tOf = (x: number) => view.current.lo + ((x - plotL) / plotW) * (view.current.hi - view.current.lo); + const yOf = (rss: number) => plotT + (1 - rss / yMax) * plotH; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + const { lo, hi } = view.current; + + // y grid + labels + ctx.strokeStyle = "#eef0f3"; + ctx.fillStyle = "#9298a2"; + ctx.font = "10px ui-monospace, monospace"; + ctx.textBaseline = "middle"; + for (let i = 0; i <= 4; i++) { + const v = (yMax * i) / 4; + const y = yOf(v); + ctx.beginPath(); + ctx.moveTo(plotL, y); + ctx.lineTo(width - PAD_R, y); + ctx.stroke(); + ctx.fillText(bytes(v), 6, y); + } + + // clip to plot for the lines + ctx.save(); + ctx.beginPath(); + ctx.rect(plotL, plotT, plotW, plotH); + ctx.clip(); + for (const s of series) { + ctx.strokeStyle = s.color; + ctx.lineWidth = 1.6; + ctx.beginPath(); + let started = false; + for (const p of s.pts) { + if (p.t < lo - 1 || p.t > hi + 1) { + started = false; + continue; + } + const x = xOf(p.t); + const y = yOf(p.rss); + if (!started) { + ctx.moveTo(x, y); + started = true; + } else ctx.lineTo(x, y); + } + ctx.stroke(); + } + // death markers + for (const d of deaths) { + const t = d.timestamp - t0; + if (t < lo || t > hi) continue; + const x = xOf(t); + ctx.strokeStyle = "#d43b3b"; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(x, plotT); + ctx.lineTo(x, plotT + plotH); + ctx.stroke(); + ctx.setLineDash([]); + } + // selected time + if (selectedTime != null) { + const t = selectedTime - t0; + if (t >= lo && t <= hi) { + ctx.strokeStyle = "#e8590c"; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(xOf(t), plotT); + ctx.lineTo(xOf(t), plotT + plotH); + ctx.stroke(); + } + } + // hover crosshair + a dot on each line at the cursor time + if (hover) { + ctx.strokeStyle = "#c9ccd2"; + ctx.beginPath(); + ctx.moveTo(hover.x, plotT); + ctx.lineTo(hover.x, plotT + plotH); + ctx.stroke(); + for (const s of series) { + let best: { t: number; rss: number } | null = null; + for (const p of s.pts) + if (!best || Math.abs(p.t - hover.t) < Math.abs(best.t - hover.t)) best = p; + if (best && best.t >= lo && best.t <= hi) { + const x = xOf(best.t); + const y = yOf(best.rss); + ctx.fillStyle = s.color; + ctx.beginPath(); + ctx.arc(x, y, 3.5, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1.2; + ctx.stroke(); + } + } + } + ctx.restore(); + + // death labels (outside clip, at top) + ctx.fillStyle = "#d43b3b"; + ctx.textBaseline = "alphabetic"; + for (const d of deaths) { + const t = d.timestamp - t0; + if (t < lo || t > hi) continue; + ctx.fillText("death", Math.min(xOf(t) + 3, width - 34), plotT - 8); + } + + // x axis + ctx.fillStyle = "#8a8f98"; + ctx.textBaseline = "top"; + for (let i = 0; i <= 8; i++) { + const t = lo + ((hi - lo) * i) / 8; + ctx.fillText(`${t.toFixed(1)}s`, Math.min(xOf(t), width - 28), plotT + plotH + 4); + } + }); + + const canvasXY = (e: React.MouseEvent) => { + const r = canvasRef.current!.getBoundingClientRect(); + return { cx: e.clientX - r.left, cy: e.clientY - r.top }; + }; + + const onWheel = (e: React.WheelEvent) => { + e.preventDefault(); + const { cx } = canvasXY(e); + if (cx < plotL) return; + const { lo, hi } = view.current; + const tc = tOf(cx); + const step = Math.min(0.25, Math.abs(e.deltaY) / 400 + 0.06); + const factor = e.deltaY < 0 ? 1 - step : 1 + step; + let nlo = tc - (tc - lo) * factor; + let nhi = tc + (hi - tc) * factor; + nlo = Math.max(0, nlo); + nhi = Math.min(domainHi, nhi); + if (nhi - nlo > 0.02) { + view.current = { lo: nlo, hi: nhi }; + redraw(); + } + }; + + const onDown = (e: React.MouseEvent) => { + const { cx } = canvasXY(e); + if (cx < plotL) return; + if (e.shiftKey) { + marquee.current = { x0: cx }; + setBox({ x: cx, w: 0 }); + return; + } + drag.current = { x: cx, lo: view.current.lo, hi: view.current.hi, moved: false }; + }; + const onMove = (e: React.MouseEvent) => { + const { cx, cy } = canvasXY(e); + if (marquee.current) { + setBox({ x: Math.min(marquee.current.x0, cx), w: Math.abs(cx - marquee.current.x0) }); + return; + } + if (drag.current) { + const dx = cx - drag.current.x; + if (Math.abs(dx) > 2) drag.current.moved = true; + const { lo, hi } = drag.current; + const dt = (dx / plotW) * (hi - lo); + let nlo = lo - dt; + let nhi = hi - dt; + const span = hi - lo; + if (nlo < 0) { + nlo = 0; + nhi = span; + } + if (nhi > domainHi) { + nhi = domainHi; + nlo = domainHi - span; + } + view.current = { lo: nlo, hi: nhi }; + setHover(null); + redraw(); + return; + } + if (cx >= plotL && cx <= width - PAD_R) setHover({ x: cx, y: cy, t: tOf(cx) }); + else setHover(null); + }; + const onUp = () => { + if (marquee.current) { + const m = box; + marquee.current = null; + setBox(null); + if (m && m.w > 6) { + const nlo = tOf(m.x); + const nhi = tOf(m.x + m.w); + if (nhi - nlo > 0.02) { + view.current = { lo: nlo, hi: nhi }; + redraw(); + } + } + return; + } + const d = drag.current; + drag.current = null; + if (d && !d.moved && onSelect) onSelect(t0 + tOf(d.x)); + }; + const reset = () => { + view.current = { lo: 0, hi: domainHi }; + redraw(); + }; + + if (samples.length === 0) return
No memory samples yet.
; + + const zoomed = view.current.lo > 1e-6 || view.current.hi < domainHi - 1e-6; + const hoverVals = + hover && + series.map((s) => { + // nearest point to hovered time + let best: { t: number; rss: number } | null = null; + for (const p of s.pts) if (!best || Math.abs(p.t - hover.t) < Math.abs(best.t - hover.t)) best = p; + return { worker: s.worker, color: s.color, rss: best?.rss ?? 0 }; + }); return (
- {workers.map((w, i) => ( - - - {w.replace("tcp://", "")} + {series.map((s) => ( + + + {shortWorker(s.worker)} ))} + {onSelect && ( + · scroll to zoom · drag to pan · shift-drag box-zoom · click to inspect + )} +
+
+ { + drag.current = null; + marquee.current = null; + setBox(null); + setHover(null); + }} + /> + {box && ( +
+ )} + {zoomed && ( + + )} + {hover && hoverVals && ( +
+
t = {hover.t.toFixed(2)}s
+ {hoverVals.map((h) => ( +
+ + {shortWorker(h.worker)} + {bytes(h.rss)} +
+ ))} +
+ )}
- - - - `${t}s`} - stroke="#e6e8ec" - /> - bytes(v)} - domain={[0, Math.ceil(peak * 1.1)]} - stroke="#e6e8ec" - width={64} - /> - `t = ${t}s`} - formatter={(v: number, name: string) => [bytes(v), name.replace("tcp://", "")]} - /> - {workers.map((w, i) => ( - - ))} - -
); } diff --git a/web/components/MemoryDeep.tsx b/web/components/MemoryDeep.tsx new file mode 100644 index 0000000..6cc78ce --- /dev/null +++ b/web/components/MemoryDeep.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useEffect } from "react"; +import { useAllocSites, useTaskMemory } from "@/lib/api"; +import { useLive } from "@/lib/live"; +import { bytes, shortKey } from "@/lib/format"; +import { baseName } from "@/lib/colors"; +import { Flamegraph } from "./Flamegraph"; + +// The 10x view: memray-derived, per-source-line memory. Answers "which line +// allocated the array that's filling memory", not just "how much is resident". +export function MemoryDeep({ runId }: { runId: string }) { + const { data: sites, mutate: mSites } = useAllocSites(runId); + const { data: tasks, mutate: mTasks } = useTaskMemory(runId); + const { deepNonce } = useLive(); + + // Pull fresh aggregates the moment a new epoch's deep data streams in. + useEffect(() => { + if (deepNonce > 0) { + mSites(); + mTasks(); + } + }, [deepNonce, mSites, mTasks]); + + const rows = sites ?? []; + const taskRows = tasks ?? []; + + if (rows.length === 0 && taskRows.length === 0) + return ( +
+ No deep memory data. Enable the memray engine for this run:{" "} + register(client, url, deep=True) or{" "} + LocalProfiler(..., deep=True). +
+ ); + + const topLine = rows[0]?.hwm_bytes ?? 1; + const topTask = taskRows[0]?.peak_rss_delta ?? 1; + + return ( + <> + + +
+
+
Peak memory by source line
+ + + + + + + + + + {rows.slice(0, 40).map((s) => ( + + + + + + ))} + +
Source linePeakAllocs
+
+ {s.filename.split("/").slice(-1)[0]}:{s.lineno} +
+
{s.function}
+
+
+ + {bytes(s.hwm_bytes)} +
+
{s.n_allocations.toLocaleString()}
+
+ +
+
Peak memory by task
+ + + + + + + + + + {taskRows.slice(0, 40).map((t) => { + const top = t.top_sites[0]; + return ( + + + + + + ); + })} + +
TaskPeak Δ RSSTop line
+
{shortKey(t.key)}
+
{baseName(t.layer)}
+
+
+ + {bytes(t.peak_rss_delta)} +
+
+ {top ? `${top.filename.split("/").slice(-1)[0]}:${top.lineno}` : "—"} +
+
+
+ + ); +} diff --git a/web/components/MemoryExplorer.tsx b/web/components/MemoryExplorer.tsx new file mode 100644 index 0000000..5391510 --- /dev/null +++ b/web/components/MemoryExplorer.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useState } from "react"; +import { useLive } from "@/lib/live"; +import { MemoryChart } from "./MemoryChart"; +import { SpikeInspector } from "./SpikeInspector"; + +// The full-page memory explorer: a large, zoomable memory-over-time chart with a +// roomy spike inspector alongside. Click any point to pin an instant and see +// what was running and allocating then. +export function MemoryExplorer({ runId }: { runId: string }) { + const { samples, spans, deaths } = useLive(); + const [pinned, setPinned] = useState(null); + + if (samples.length === 0) return
No memory samples yet.
; + + return ( +
+
+ +
+
+ setPinned(null)} + /> +
+
+ ); +} diff --git a/web/components/PostMortem.tsx b/web/components/PostMortem.tsx index 64264ea..57745b6 100644 --- a/web/components/PostMortem.tsx +++ b/web/components/PostMortem.tsx @@ -1,8 +1,9 @@ "use client"; -import { useDeaths, useGraph } from "@/lib/api"; +import { useGraph } from "@/lib/api"; +import { useLive } from "@/lib/live"; import { bytes, layerToken, shortKey } from "@/lib/format"; -import type { ChunkMeta, GraphLayer } from "@/lib/types"; +import type { AllocationSite, ChunkMeta, GraphLayer } from "@/lib/types"; import { CodeLine } from "./CodeLine"; function sourceFor(key: string, layers: GraphLayer[]): GraphLayer | undefined { @@ -23,14 +24,31 @@ function Chunks({ chunks }: { chunks: ChunkMeta[] }) { ); } +// The deep-memory cause: the exact source lines at the high-water mark when the +// worker died — the "line 42 allocated 12.8 GB" answer above the chunk view. +function Sites({ sites }: { sites: AllocationSite[] }) { + if (sites.length === 0) return null; + return ( +
+
At the high-water mark:
+ {sites.slice(0, 5).map((s, i) => ( +
+ + {s.filename.split("/").slice(-1)[0]}:{s.lineno} {s.function} + + {bytes(s.hwm_bytes)} +
+ ))} +
+ ); +} + export function PostMortem({ runId }: { runId: string }) { - const { data: deaths, isLoading } = useDeaths(runId); + const { deaths } = useLive(); const { data: graph } = useGraph(runId); const layers = graph?.layers ?? []; - if (isLoading) return
Loading…
; - - const relevant = (deaths ?? []).filter((d) => d.suspect_keys.length > 0); + const relevant = deaths.filter((d) => d.suspect_keys.length > 0); if (relevant.length === 0) return (
@@ -55,6 +73,7 @@ export function PostMortem({ runId }: { runId: string }) { {d.worker}
{d.reason}
+ {d.suspect_keys.map((key) => { const src = sourceFor(key, layers); const chunks = d.suspect_chunks.filter((c) => c.task_key === key); diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index 57b333d..55499e4 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -2,14 +2,40 @@ import Link from "next/link"; import { useParams } from "next/navigation"; +import { useEffect } from "react"; import { useRuns } from "@/lib/api"; +import { collectorWsBase } from "@/lib/live"; import { ago } from "@/lib/format"; export function Sidebar() { - const { data: runs } = useRuns(); + const { data: runs, mutate } = useRuns(); const params = useParams(); const activeId = params?.id as string | undefined; + // Live run list: the collector pushes a nudge whenever a run is created, + // deleted, or gains a death, so the sidebar updates without a page refresh. + useEffect(() => { + const base = collectorWsBase(); + if (!base) return; + let ws: WebSocket | null = null; + let closed = false; + let retry: ReturnType; + const connect = () => { + ws = new WebSocket(`${base}/ws/runs`); + ws.onmessage = () => mutate(); + ws.onclose = () => { + if (!closed) retry = setTimeout(connect, 1500); + }; + ws.onerror = () => ws?.close(); + }; + connect(); + return () => { + closed = true; + clearTimeout(retry); + ws?.close(); + }; + }, [mutate]); + return ( <>
@@ -37,6 +63,11 @@ export function Sidebar() {
{ago(r.created_at)} · {r.counts.workers ?? 0}w · {r.counts.samples ?? 0}s
+ {r.origin || r.origin_ip ? ( +
+ {r.origin || r.origin_ip} +
+ ) : null} )) )} diff --git a/web/components/SpikeInspector.tsx b/web/components/SpikeInspector.tsx new file mode 100644 index 0000000..91b007e --- /dev/null +++ b/web/components/SpikeInspector.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useMemo } from "react"; +import useSWR from "swr"; +import { useGraph } from "@/lib/api"; +import { baseName } from "@/lib/colors"; +import { bytes, shortKey } from "@/lib/format"; +import type { AllocSiteRow, GraphLayer, Sample, TaskSpan } from "@/lib/types"; +import { CodeLine } from "./CodeLine"; + +const WINDOW = 3; // seconds around the click to attribute allocations to + +const fetcher = (u: string) => fetch(u).then((r) => r.json()); + +function sourceForLayer(layer: string, layers: GraphLayer[]): GraphLayer | undefined { + return layers.find((l) => layer === l.layer || layer.startsWith(l.layer) || l.layer.startsWith(layer)); +} + +// Given a pinned instant, answer "what was happening here": which tasks were +// running (where we were in the graph), what they cost in memory, and which +// source lines were allocating in that window — the "what caused this spike". +export function SpikeInspector({ + runId, + time, + samples, + spans, + onClose, +}: { + runId: string; + time: number | null; + samples: Sample[]; + spans: TaskSpan[]; + onClose: () => void; +}) { + const { data: graph } = useGraph(runId); + const { data: sites } = useSWR( + time != null + ? `/api/runs/${runId}/alloc-sites?start=${time - WINDOW}&end=${time + WINDOW}` + : null, + fetcher, + ); + + const t0 = useMemo( + () => (samples.length ? Math.min(...samples.map((s) => s.timestamp)) : 0), + [samples], + ); + + const info = useMemo(() => { + if (time == null) return null; + // RSS per worker at the instant (nearest sample within 2s) + const perWorker = new Map(); + const nearest = new Map(); + for (const s of samples) { + if (!s.worker) continue; + const dt = Math.abs(s.timestamp - time); + const cur = nearest.get(s.worker); + if (!cur || dt < cur.dt) nearest.set(s.worker, { dt, rss: s.rss_bytes }); + } + for (const [w, v] of nearest) if (v.dt < 2) perWorker.set(w, v.rss); + const totalRss = [...perWorker.values()].reduce((a, b) => a + b, 0); + + // tasks whose span covers this instant → "where we were in the graph" + const running = spans + .filter((s) => s.start <= time && s.end >= time) + .sort((a, b) => b.end - b.start - (a.end - a.start)); + + return { perWorker, totalRss, running }; + }, [time, samples, spans]); + + if (time == null) + return ( +
+ Click any point on the memory chart to see what was running and allocating at that instant. +
+ ); + + const layers = graph?.layers ?? []; + const rel = (time - t0).toFixed(1); + + return ( +
+
+ + Memory at +{rel}s · {info ? bytes(info.totalRss) : "—"} total RSS + + +
+ +
+
Running here ({info?.running.length ?? 0})
+ {info && info.running.length > 0 ? ( +
+ {info.running.slice(0, 8).map((s, i) => { + const src = sourceForLayer(s.layer, layers); + return ( +
+
+ {shortKey(s.key)} + {s.worker.replace(/^tcp:\/\//, "")} +
+
{baseName(s.layer)}
+ {src ? ( + <> +
+ {src.filename}:{src.lineno} +
+ + + ) : null} +
+ ); + })} +
+ ) : ( +
No task span covers this instant.
+ )} + +
+ Allocating in this window (±{WINDOW}s) +
+ {sites && sites.length > 0 ? ( + + + {sites.slice(0, 8).map((s, i) => ( + + + + + ))} + +
+ {s.filename.split("/").slice(-1)[0]}:{s.lineno} + {s.function} + + {bytes(s.hwm_bytes)} +
+ ) : ( +
+ No deep allocation data here — enable deep=True for line-level cause. +
+ )} +
+
+ ); +} diff --git a/web/components/TaskGraph.tsx b/web/components/TaskGraph.tsx index 7dc8c02..8db11a8 100644 --- a/web/components/TaskGraph.tsx +++ b/web/components/TaskGraph.tsx @@ -12,17 +12,20 @@ import { Position, ReactFlow, } from "@xyflow/react"; -import { useMemo, useState } from "react"; -import { useChunks, useDeaths, useGraph } from "@/lib/api"; +import { useEffect, useMemo, useState } from "react"; +import { useChunks, useGraph } from "@/lib/api"; +import { useLive } from "@/lib/live"; import { baseName, layerColorMap } from "@/lib/colors"; import { bytes, layerToken, shortKey } from "@/lib/format"; import type { GraphData, GraphLayer } from "@/lib/types"; import { CodeLine } from "./CodeLine"; +import { GraphCanvas, type CNode } from "./GraphCanvas"; const NODE_W = 168; const NODE_H = 38; -// Above this many task nodes we render the layer-level graph instead — a few -// hundred nodes is the sweet spot for a readable, responsive DAG. +// Above this many task nodes we switch from the interactive react-flow view to +// the canvas DAG renderer, which draws the *whole* graph (thousands of nodes) +// rather than collapsing to a layer summary. const TASK_LIMIT = 400; type NodeData = { label: string; layer: string; color: string; hot: boolean }; @@ -69,8 +72,17 @@ function laidOut( return { nodes, edges }; } -function build(graph: GraphData, hot: Set, colorOf: (l: string) => string) { - const taskLevel = graph.nodes.length > 0 && graph.nodes.length <= TASK_LIMIT && !graph.truncated; +function build( + graph: GraphData, + hot: Set, + colorOf: (l: string) => string, + allowTaskLevel = true, +) { + const taskLevel = + allowTaskLevel && + graph.nodes.length > 0 && + graph.nodes.length <= TASK_LIMIT && + !graph.truncated; if (taskLevel) { const raw = graph.nodes.map((n) => ({ id: n.key, @@ -160,23 +172,50 @@ function SourcePanel({ } export function TaskGraph({ runId }: { runId: string }) { - const { data: graph, isLoading } = useGraph(runId); - const { data: deaths } = useDeaths(runId); + const { data: graph, isLoading, mutate } = useGraph(runId); + const { deaths, graphNonce } = useLive(); const [selected, setSelected] = useState(null); + // For big graphs the readable default is the layer-level DAG; drilling into + // the full task canvas is opt-in (thousands of nodes is a hairball otherwise). + const [showTasks, setShowTasks] = useState(false); + + // The graph is uploaded once the collection is known; refetch when the live + // stream signals a (re)upload landed. + useEffect(() => { + if (graphNonce > 0) mutate(); + }, [graphNonce, mutate]); const hot = useMemo( - () => new Set((deaths ?? []).flatMap((d) => d.suspect_keys.map(layerToken))), + () => new Set(deaths.flatMap((d) => d.suspect_keys.map(layerToken))), [deaths], ); const colorOf = useMemo(() => layerColorMap(), []); + const hasTasks = !!graph && graph.nodes.length > 0 && !graph.truncated; + const large = !!graph && graph.nodes.length > TASK_LIMIT && !graph.truncated; + // Show the full task canvas only for small graphs, or when the user opts in. + const useCanvas = hasTasks && (large ? showTasks : false); + const useTaskReactFlow = hasTasks && !large; // small graph → interactive task view + + const canvasNodes = useMemo(() => { + if (!graph || !useCanvas) return []; + return graph.nodes.map((n) => ({ + id: n.key, + label: shortKey(n.key), + layer: n.layer, + color: colorOf(n.layer), + hot: [...hot].some((t) => n.key.includes(t) || layerToken(n.key).startsWith(t)), + })); + }, [graph, useCanvas, hot, colorOf]); + + // react-flow path: the interactive task view (small graphs) or the layer DAG. const { nodes, edges, taskLevel } = useMemo( () => - graph - ? build(graph, hot, colorOf) + graph && !useCanvas + ? build(graph, hot, colorOf, useTaskReactFlow) : { nodes: [] as Node[], edges: [] as Edge[], taskLevel: false }, - [graph, hot, colorOf], + [graph, useCanvas, useTaskReactFlow, hot, colorOf], ); if (isLoading) return
Loading…
; @@ -191,40 +230,68 @@ export function TaskGraph({ runId }: { runId: string }) { const layerOf = new Map(graph.nodes.map((n) => [n.key, n.layer])); const sourceOf = new Map(graph.layers.map((l) => [l.layer, l])); - const selLayer = selected ? (taskLevel ? layerOf.get(selected) ?? "" : selected) : ""; + const selTaskLevel = useCanvas || taskLevel; + const selLayer = selected ? (selTaskLevel ? layerOf.get(selected) ?? "" : selected) : ""; + + const note = useCanvas + ? `${graph.task_count} tasks · full task graph` + : taskLevel + ? `${graph.task_count} tasks · task-level graph` + : `${graph.task_count} tasks · grouped by layer (${ + graph.layers.length || new Set(graph.nodes.map((n) => n.layer)).size + } layers)`; return ( <>
- {graph.task_count} tasks ·{" "} - {taskLevel ? "task-level graph" : "layer-level graph (too large for task view)"} · click a - node for its source + {note} · click a node for its source + {large && ( + + )}
-
- setSelected(node.id)} - > - - - (n.data as NodeData).color} /> - -
+ {useCanvas ? ( + + ) : ( +
+ setSelected(node.id)} + > + + + (n.data as NodeData).color} /> + +
+ )} {selected && ( setSelected(null)} /> )} diff --git a/web/components/TaskStream.tsx b/web/components/TaskStream.tsx new file mode 100644 index 0000000..ec3a370 --- /dev/null +++ b/web/components/TaskStream.tsx @@ -0,0 +1,327 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useLive } from "@/lib/live"; +import { baseName, layerColorMap } from "@/lib/colors"; +import { shortKey } from "@/lib/format"; +import type { TaskSpan } from "@/lib/types"; + +const LANE_H = 18; +const LANE_GAP = 3; +const SECTION_GAP = 16; +const GUTTER = 96; // left label column (fixed, not zoomed) +const PAD_R = 10; +const PAD_T = 8; +const AXIS_H = 18; + +type Hover = { x: number; y: number; span: TaskSpan } | null; + +// Greedy interval packing: assign each span to the first lane whose last task +// has finished — the compact "global" activity view across all workers. +function packLanes(spans: TaskSpan[]): { span: TaskSpan; lane: number }[] { + const sorted = [...spans].sort((a, b) => a.start - b.start); + const laneEnds: number[] = []; + const out: { span: TaskSpan; lane: number }[] = []; + for (const s of sorted) { + let lane = laneEnds.findIndex((end) => end <= s.start); + if (lane === -1) { + lane = laneEnds.length; + laneEnds.push(s.end); + } else { + laneEnds[lane] = s.end; + } + out.push({ span: s, lane }); + } + return out; +} + +export function TaskStream() { + const { spans } = useLive(); + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const [width, setWidth] = useState(900); + const [hover, setHover] = useState(null); + const colorOf = useMemo(() => layerColorMap(), []); + + const domain = useMemo(() => { + if (spans.length === 0) return { lo: 0, hi: 1 }; + let lo = Infinity; + let hi = -Infinity; + for (const s of spans) { + if (s.start < lo) lo = s.start; + if (s.end > hi) hi = s.end; + } + return { lo, hi: hi <= lo ? lo + 1 : hi }; + }, [spans]); + + // the visible time window (zoom/pan state) + const view = useRef({ lo: domain.lo, hi: domain.hi }); + const [, force] = useState(0); + const redraw = () => force((n) => n + 1); + const drag = useRef<{ x: number; lo: number; hi: number } | null>(null); + const marquee = useRef<{ x0: number } | null>(null); + const [box, setBox] = useState<{ x: number; w: number } | null>(null); + + // reset the window when the run/domain changes + useEffect(() => { + view.current = { lo: domain.lo, hi: domain.hi }; + redraw(); + }, [domain.lo, domain.hi]); + + const workers = useMemo(() => Array.from(new Set(spans.map((s) => s.worker))).sort(), [spans]); + const global = useMemo(() => packLanes(spans), [spans]); + const globalLanes = useMemo(() => Math.max(1, ...global.map((g) => g.lane + 1)), [global]); + const laneOf = useMemo(() => { + const m = new Map(); + workers.forEach((w, i) => m.set(w, i)); + return m; + }, [workers]); + + const globalH = globalLanes * (LANE_H + LANE_GAP); + const workerH = workers.length * (LANE_H + LANE_GAP); + const globalTop = PAD_T + 14; // room for a section label + const workerTop = globalTop + globalH + SECTION_GAP + 14; + const height = workerTop + workerH + AXIS_H; + + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setWidth(el.clientWidth)); + ro.observe(el); + setWidth(el.clientWidth); + return () => ro.disconnect(); + }, []); + + const plotL = GUTTER; + const plotW = Math.max(1, width - GUTTER - PAD_R); + const xOf = (t: number) => { + const { lo, hi } = view.current; + return plotL + ((t - lo) / (hi - lo)) * plotW; + }; + const tOf = (x: number) => { + const { lo, hi } = view.current; + return lo + ((x - plotL) / plotW) * (hi - lo); + }; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + const { lo, hi } = view.current; + + // section labels + ctx.fillStyle = "#62666d"; + ctx.font = "600 11px ui-monospace, monospace"; + ctx.textBaseline = "alphabetic"; + ctx.fillText("ALL TASKS", 6, globalTop - 3); + ctx.fillText("PER WORKER", 6, workerTop - 3); + + const drawRect = (s: TaskSpan, y: number) => { + if (s.end < lo || s.start > hi) return; + const x0 = Math.max(plotL, xOf(s.start)); + const x1 = Math.min(width - PAD_R, xOf(s.end)); + if (x1 <= x0) return; + ctx.fillStyle = colorOf(s.layer); + ctx.fillRect(x0, y, Math.max(1, x1 - x0), LANE_H); + }; + + // global lane backgrounds + rects + ctx.fillStyle = "#f4f5f7"; + for (let i = 0; i < globalLanes; i++) + ctx.fillRect(plotL, globalTop + i * (LANE_H + LANE_GAP), plotW, LANE_H); + for (const g of global) drawRect(g.span, globalTop + g.lane * (LANE_H + LANE_GAP)); + + // per-worker lanes + ctx.fillStyle = "#f7f8fa"; + workers.forEach((_, i) => + ctx.fillRect(plotL, workerTop + i * (LANE_H + LANE_GAP), plotW, LANE_H), + ); + ctx.fillStyle = "#62666d"; + ctx.font = "10px ui-monospace, monospace"; + workers.forEach((w, i) => { + const y = workerTop + i * (LANE_H + LANE_GAP); + ctx.fillStyle = "#8a8f98"; + ctx.fillText(w.replace(/^tcp:\/\//, "").slice(0, 15), 6, y + LANE_H - 5); + }); + for (const s of spans) { + const lane = laneOf.get(s.worker) ?? 0; + drawRect(s, workerTop + lane * (LANE_H + LANE_GAP)); + } + + // axis + ctx.fillStyle = "#8a8f98"; + ctx.font = "10px ui-monospace, monospace"; + ctx.textBaseline = "top"; + const ticks = 8; + for (let i = 0; i <= ticks; i++) { + const t = lo + ((hi - lo) * i) / ticks; + const x = xOf(t); + if (x < plotL - 1) continue; + ctx.fillText(`${(t - domain.lo).toFixed(1)}s`, Math.min(x, width - 28), height - AXIS_H + 2); + } + }); + + const canvasXY = (e: React.MouseEvent) => { + const r = canvasRef.current!.getBoundingClientRect(); + return { cx: e.clientX - r.left, cy: e.clientY - r.top }; + }; + + const onWheel = (e: React.WheelEvent) => { + e.preventDefault(); + const { cx } = canvasXY(e); + if (cx < plotL) return; + const { lo, hi } = view.current; + const tc = tOf(cx); + const step = Math.min(0.25, Math.abs(e.deltaY) / 400 + 0.06); + const factor = e.deltaY < 0 ? 1 - step : 1 + step; // in = shrink window + let nlo = tc - (tc - lo) * factor; + let nhi = tc + (hi - tc) * factor; + // clamp to domain and a min window + nlo = Math.max(domain.lo, nlo); + nhi = Math.min(domain.hi, nhi); + if (nhi - nlo > 0.02) { + view.current = { lo: nlo, hi: nhi }; + redraw(); + } + }; + + const onDown = (e: React.MouseEvent) => { + const { cx } = canvasXY(e); + if (cx < plotL) return; + if (e.shiftKey) { + marquee.current = { x0: cx }; + setBox({ x: cx, w: 0 }); + return; + } + drag.current = { x: cx, lo: view.current.lo, hi: view.current.hi }; + }; + const onMove = (e: React.MouseEvent) => { + const { cx, cy } = canvasXY(e); + if (marquee.current) { + setBox({ x: Math.min(marquee.current.x0, cx), w: Math.abs(cx - marquee.current.x0) }); + return; + } + if (drag.current) { + const { lo, hi } = drag.current; + const dt = ((cx - drag.current.x) / plotW) * (hi - lo); + let nlo = lo - dt; + let nhi = hi - dt; + const span = hi - lo; + if (nlo < domain.lo) { + nlo = domain.lo; + nhi = domain.lo + span; + } + if (nhi > domain.hi) { + nhi = domain.hi; + nlo = domain.hi - span; + } + view.current = { lo: nlo, hi: nhi }; + setHover(null); + redraw(); + return; + } + // hover hit-test + const t = tOf(cx); + const yrow = (top: number, n: number) => { + const i = Math.floor((cy - top) / (LANE_H + LANE_GAP)); + return i >= 0 && i < n ? i : -1; + }; + let found: TaskSpan | null = null; + const gi = yrow(globalTop, globalLanes); + if (gi >= 0) { + for (const g of global) + if (g.lane === gi && g.span.start <= t && g.span.end >= t) found = g.span; + } else { + const wi = yrow(workerTop, workers.length); + if (wi >= 0) { + const w = workers[wi]; + for (const s of spans) + if (s.worker === w && s.start <= t && s.end >= t) found = s; + } + } + setHover(found ? { x: cx, y: cy, span: found } : null); + }; + const onUp = () => { + if (marquee.current) { + const m = box; + marquee.current = null; + setBox(null); + if (m && m.w > 6) { + const nlo = tOf(m.x); + const nhi = tOf(m.x + m.w); + if (nhi - nlo > 0.02) { + view.current = { lo: nlo, hi: nhi }; + redraw(); + } + } + return; + } + drag.current = null; + }; + const reset = () => { + view.current = { lo: domain.lo, hi: domain.hi }; + redraw(); + }; + + if (spans.length === 0) return
No task spans yet.
; + + const zoomed = view.current.lo > domain.lo + 1e-6 || view.current.hi < domain.hi - 1e-6; + + return ( +
+ { + drag.current = null; + marquee.current = null; + setBox(null); + setHover(null); + }} + /> + {box && ( +
+ )} +
+ {zoomed && ( + + )} +
+ {hover && ( +
+
{shortKey(hover.span.key)}
+
{baseName(hover.span.layer)}
+
+ {(hover.span.end - hover.span.start).toFixed(3)}s ·{" "} + {hover.span.worker.replace(/^tcp:\/\//, "")} +
+
+ )} +
+ scroll to zoom time · drag to pan · shift-drag to box-zoom · hover a task +
+
+ ); +} diff --git a/web/components/WorkersView.tsx b/web/components/WorkersView.tsx new file mode 100644 index 0000000..f7f97ec --- /dev/null +++ b/web/components/WorkersView.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useMemo } from "react"; +import { useLive } from "@/lib/live"; +import { bytes } from "@/lib/format"; +import type { WorkerStatus } from "@/lib/types"; + +// A memory-pressure colour: green under half, amber past 70%, red past 90% of +// the worker's limit — the same read the native Dask workers page gives you. +function pressure(frac: number): string { + if (frac >= 0.9) return "var(--danger)"; + if (frac >= 0.7) return "var(--warn)"; + return "var(--ok)"; +} + +function Bar({ used, limit }: { used: number; limit: number }) { + const frac = limit > 0 ? Math.min(1, used / limit) : 0; + return ( +
0 ? `${bytes(used)} / ${bytes(limit)}` : bytes(used)}> + + {limit > 0 ? `${Math.round(frac * 100)}%` : bytes(used)} +
+ ); +} + +export function WorkersView() { + const { statuses } = useLive(); + const rows = useMemo( + () => Object.values(statuses).sort((a, b) => a.worker.localeCompare(b.worker)), + [statuses], + ); + + if (rows.length === 0) return
No worker heartbeats yet.
; + + const totalRss = rows.reduce((a, w) => a + w.rss_bytes, 0); + const totalExec = rows.reduce((a, w) => a + w.executing, 0); + + return ( + <> +
+
+
{rows.length}
+
Workers
+
+
+
{bytes(totalRss)}
+
Total RSS
+
+
+
{totalExec}
+
Tasks executing
+
+
+ + + + + + + + + + + + + + + {rows.map((w) => ( + + + + + + + + + + ))} + +
WorkerMemoryManagedCPUThreadsExecutingReady
+ 0 ? "busy" : "idle"}`} /> {w.worker} + + + {w.managed_bytes ? bytes(w.managed_bytes) : "—"}{w.cpu.toFixed(0)}%{w.nthreads || "—"}{w.executing}{w.ready}
+ + ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 9d08c1c..6a485fb 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -2,13 +2,18 @@ import useSWR from "swr"; import type { + AllocSiteRow, + AllocTimelineRow, ChunkMeta, DeathEvent, + FlameData, GraphData, LayerStat, RunInfo, Sample, + TaskMemoryRow, TaskSpan, + WorkerStatus, } from "./types"; const fetcher = async (url: string) => { @@ -48,6 +53,31 @@ export function useLayerStats(id: string) { return useSWR(`/api/runs/${id}/layer-stats`, fetcher, LIVE); } +export function useWorkers(id: string) { + return useSWR(`/api/runs/${id}/workers`, fetcher, LIVE); +} + +export function useAllocSites(id: string) { + return useSWR(`/api/runs/${id}/alloc-sites`, fetcher, LIVE); +} + +export function useTaskMemory(id: string) { + return useSWR(`/api/runs/${id}/task-memory`, fetcher, LIVE); +} + +export function useAllocTimeline(id: string) { + return useSWR(`/api/runs/${id}/alloc-timeline`, fetcher, LIVE); +} + +export function useFlamegraph(id: string, worker: string | null) { + const q = worker ? `?worker=${encodeURIComponent(worker)}` : ""; + // keepPreviousData so switching worker doesn't blank the panel mid-fetch. + return useSWR(`/api/runs/${id}/flamegraph${q}`, fetcher, { + ...LIVE, + keepPreviousData: true, + }); +} + export function useChunks(id: string, key: string | null) { return useSWR( key ? `/api/runs/${id}/chunks/${encodeURIComponent(key)}` : null, diff --git a/web/lib/live.tsx b/web/lib/live.tsx new file mode 100644 index 0000000..3023463 --- /dev/null +++ b/web/lib/live.tsx @@ -0,0 +1,192 @@ +"use client"; + +// Real-time run state. One WebSocket per open run streams the collector's +// ingest as it happens; we seed from REST once, then apply each frame as a +// delta into in-memory rings. Components read the live state through useLive() +// instead of polling — the memory chart, Workers table and task stream all +// update the instant a worker flushes. + +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { DeathEvent, Sample, TaskSpan, WorkerStatus } from "./types"; + +const SAMPLE_CAP = 6000; // ~ tens of minutes at 0.2s across a few workers +const SPAN_CAP = 20000; + +type Frame = + | { + type: "batch"; + worker: string; + samples: Sample[]; + spans: TaskSpan[]; + statuses: WorkerStatus[]; + epochs: unknown[]; + task_memory: unknown[]; + } + | { type: "death"; data: DeathEvent } + | { type: "graph" }; + +export interface LiveState { + connected: boolean; + // Wall-clock (ms) of the last data frame received. A run is only "live" if + // data is actually still arriving — a finished run has an open socket but no + // recent frames, so the UI shows it idle rather than falsely "live". + lastFrameAt: number; + samples: Sample[]; + statuses: Record; + spans: TaskSpan[]; + deaths: DeathEvent[]; + // Bumps when the deep-memory stream or graph advances, so views backed by + // aggregate REST endpoints (alloc-sites, task-memory, graph) can revalidate. + deepNonce: number; + graphNonce: number; +} + +const empty: LiveState = { + connected: false, + lastFrameAt: 0, + samples: [], + statuses: {}, + spans: [], + deaths: [], + deepNonce: 0, + graphNonce: 0, +}; + +const LiveContext = createContext(empty); + +export function useLive(): LiveState { + return useContext(LiveContext); +} + +export function collectorWsBase(): string { + const env = process.env.NEXT_PUBLIC_COLLECTOR_WS; + if (env) return env.replace(/\/$/, ""); + // Default: the collector's published port on the same host the dashboard is + // served from (docker-compose maps 8765:8765; dev runs it on localhost). + if (typeof window !== "undefined") { + const proto = window.location.protocol === "https:" ? "wss" : "ws"; + return `${proto}://${window.location.hostname}:8765`; + } + return ""; +} + +function collectorWsUrl(runId: string): string { + const base = collectorWsBase(); + return base ? `${base}/ws/runs/${runId}` : ""; +} + +export function LiveProvider({ + runId, + children, +}: { + runId: string; + children: React.ReactNode; +}) { + const [state, setState] = useState(empty); + const ref = useRef(empty); + const set = (next: LiveState) => { + ref.current = next; + setState(next); + }; + + // Seed from REST once so the views aren't empty before the first frame. + useEffect(() => { + let cancelled = false; + async function seed() { + try { + const [timeline, workers, spans, deaths] = await Promise.all([ + fetch(`/api/runs/${runId}/timeline`).then((r) => r.json()), + fetch(`/api/runs/${runId}/workers`).then((r) => r.json()), + fetch(`/api/runs/${runId}/spans`).then((r) => r.json()), + fetch(`/api/runs/${runId}/deaths`).then((r) => r.json()), + ]); + if (cancelled) return; + const statuses: Record = {}; + for (const w of workers as WorkerStatus[]) statuses[w.worker] = w; + set({ + ...ref.current, + samples: (timeline as Sample[]).slice().sort((a, b) => a.timestamp - b.timestamp), + statuses, + spans: spans as TaskSpan[], + deaths: deaths as DeathEvent[], + }); + } catch { + /* collector not up yet; the WS will fill in */ + } + } + seed(); + return () => { + cancelled = true; + }; + }, [runId]); + + // Live WebSocket with auto-reconnect. + useEffect(() => { + const url = collectorWsUrl(runId); + if (!url) return; + let ws: WebSocket | null = null; + let closed = false; + let retry: ReturnType; + + const connect = () => { + ws = new WebSocket(url); + ws.onopen = () => set({ ...ref.current, connected: true }); + ws.onclose = () => { + set({ ...ref.current, connected: false }); + if (!closed) retry = setTimeout(connect, 1500); + }; + ws.onerror = () => ws?.close(); + ws.onmessage = (ev) => { + let frame: Frame; + try { + frame = JSON.parse(ev.data); + } catch { + return; + } + apply(frame); + }; + }; + + const apply = (frame: Frame) => { + const cur = { ...ref.current, lastFrameAt: Date.now() }; + if (frame.type === "batch") { + const statuses = { ...cur.statuses }; + for (const s of frame.statuses) statuses[s.worker] = s; + // A batch's MemorySamples don't carry the worker (it's on the envelope); + // tag them so the memory chart can key lines by worker. + const tagged = frame.samples.map((s) => ({ ...s, worker: frame.worker })); + const samples = cur.samples.concat(tagged); + const spans = cur.spans.concat(frame.spans); + const deep = frame.epochs.length || frame.task_memory.length; + set({ + ...cur, + statuses, + samples: samples.length > SAMPLE_CAP ? samples.slice(-SAMPLE_CAP) : samples, + spans: spans.length > SPAN_CAP ? spans.slice(-SPAN_CAP) : spans, + deepNonce: deep ? cur.deepNonce + 1 : cur.deepNonce, + }); + } else if (frame.type === "death") { + set({ ...cur, deaths: [frame.data, ...cur.deaths] }); + } else if (frame.type === "graph") { + set({ ...cur, graphNonce: cur.graphNonce + 1 }); + } + }; + + connect(); + return () => { + closed = true; + clearTimeout(retry); + ws?.close(); + }; + }, [runId]); + + const value = useMemo(() => state, [state]); + return {children}; +} diff --git a/web/lib/types.ts b/web/lib/types.ts index c87fac1..c21f406 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -4,6 +4,8 @@ export interface RunInfo { id: string; name: string; created_at: number; + origin?: string; + origin_ip?: string; counts: { samples?: number; deaths?: number; workers?: number }; } @@ -14,15 +16,79 @@ export interface ChunkMeta { nbytes: number; } +export interface AllocationSite { + filename: string; + lineno: number; + function: string; + hwm_bytes: number; + n_allocations: number; + task_key: string; + layer: string; +} + export interface DeathEvent { timestamp: number; worker: string; suspect_keys: string[]; suspect_chunks: ChunkMeta[]; + suspect_sites: AllocationSite[]; suspected_oom: boolean; reason: string; } +export interface WorkerStatus { + worker: string; + timestamp: number; + rss_bytes: number; + managed_bytes: number; + memory_limit: number; + cpu: number; + nthreads: number; + executing: number; + ready: number; +} + +// Aggregated per-source-line deep memory (peak bytes across epochs). +export interface AllocSiteRow { + filename: string; + lineno: number; + function: string; + hwm_bytes: number; + n_allocations: number; + layers: string[]; +} + +export interface TaskMemoryRow { + key: string; + layer: string; + worker: string; + peak_rss_delta: number; + top_sites: AllocationSite[]; +} + +export interface FlameFrame { + function: string; + filename: string; + lineno: number; +} + +export interface FlameStack { + frames: FlameFrame[]; + hwm_bytes: number; + n_allocations: number; +} + +export interface FlameData { + workers: string[]; + stacks: FlameStack[]; +} + +export interface AllocTimelineRow { + ts: number; + layer: string; + bytes: number; +} + export interface Sample { worker: string; timestamp: number; diff --git a/web/public/logo.svg b/web/public/logo.svg new file mode 100644 index 0000000..9b8795f --- /dev/null +++ b/web/public/logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + From 0a7448fe846dd3b14a38b6c0b9e62d8c6d52c908 Mon Sep 17 00:00:00 2001 From: polymood <36277904+polymood@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:49:52 +0200 Subject: [PATCH 3/4] feat: examples across Dask + packaging, docs, CI - examples covering the breadth of Dask: distributed/deep OOM demos, a minutes-long big pipeline, a self-limiting crash, and one per collection type (dask.delayed, dask.dataframe, dask.bag, xarray on Zarr and NetCDF). - Packaging: PyPI metadata (authors, urls, classifiers), MIT LICENSE. - CI workflow: ruff + mypy + pytest + package build + web build. - README rewritten; examples/README expanded. --- .github/workflows/ci.yml | 39 ++++ LICENSE | 21 +++ README.md | 333 ++++++++++++++++------------------- examples/README.md | 24 +++ examples/big_pipeline_oom.py | 192 ++++++++++++++++++++ examples/dask_bag.py | 55 ++++++ examples/dask_dataframe.py | 62 +++++++ examples/dask_delayed.py | 77 ++++++++ examples/deep_oom.py | 72 ++++++++ examples/distributed_oom.py | 4 +- examples/threaded_big.py | 86 +++++++++ examples/threaded_crash.py | 93 ++++++++++ examples/xarray_netcdf.py | 63 +++++++ examples/xarray_zarr.py | 66 +++++++ 14 files changed, 1003 insertions(+), 184 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 examples/big_pipeline_oom.py create mode 100644 examples/dask_bag.py create mode 100644 examples/dask_dataframe.py create mode 100644 examples/dask_delayed.py create mode 100644 examples/deep_oom.py create mode 100644 examples/threaded_big.py create mode 100644 examples/threaded_crash.py create mode 100644 examples/xarray_netcdf.py create mode 100644 examples/xarray_zarr.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ecfea8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Sync (dev + collector + deep) + run: uv sync --group dev --extra collector --extra deep + - name: Lint + run: uv run ruff check . + - name: Format check + run: uv run ruff format --check . + - name: Type check + run: uv run mypy src/ + - name: Tests (unit) + run: uv run pytest -q -m "not integration" + - name: Build package + run: uv build + + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npm ci + - run: npm run build diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fab0d2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 polymood + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 893bd4b..efc0c88 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,111 @@ -# DaskGenie - -A code-to-graph-to-memory profiler for Dask: when a worker dies from an OOM, -know exactly which chunk killed it and which line of your code produced it. - -Built incrementally, each stage proven before the next is built. See -**Status** below for what's real today. - -## Status - -- [x] **GraphCapture.** Maps Dask task-graph layers back to the user source - line that built them. No UI. This is the de-risking step: prove source - attribution works before building anything on top of it. -- [x] **WorkerPlugin + Collector.** Per-task RSS / managed-memory sampling - with input chunk metadata, batched to a FastAPI collector that stores to - SQLite, exposes Prometheus `/metrics`, and serves a query API. -- [x] **SchedulerPlugin — the v1 success criterion.** Tracks which tasks are - in flight on which worker and, on a worker death, records the suspect - tasks; the collector joins their chunk metadata so `curl /api/deaths` - answers *"which chunk killed this worker, and what code produced it."* -- [x] **Dashboard (Next.js) + Docker.** A standalone dashboard (light theme) - over the collector API: runs list, per-run overview, post-mortem with - syntax-highlighted source, memory timeline, and the task-graph DAG. - `docker compose up` runs collector + dashboard, persisted on a volume. -- [x] **Any scheduler.** `LocalProfiler` (Dask callback API) profiles memory + - per-task chunks on the threaded/synchronous/processes schedulers, not - just `dask.distributed`. Runnable `examples/`. -- [ ] Next: aligned execution-ordered view, memory flamegraphs, on-demand - single-task memray, TimescaleDB backend. - -## The dashboard (always-on, via Docker) - -Two services — the **collector** (JSON API + Prometheus `/metrics` + SQLite, -port 8765) and the **Next.js dashboard** (port 3000) — both persisted and -restart-on-failure: +

+ DaskGenie logo +

+ +

DaskGenie

+ +A memory profiler and live dashboard for Dask that ties a worker's memory back to +the **line of your code** that caused it. + +DaskGenie fuses [memray](https://github.com/bloomberg/memray)-deep allocation +tracing with Dask's task graph and streams it to a real-time dashboard. When a +worker dies from an out-of-memory error, you can see the suspect task, the chunk +it was holding, and the exact source line that allocated the array that killed +it — not just "a worker disappeared". It works on `dask.distributed` and on the +local (threaded / processes / synchronous) schedulers, across `dask.array`, +`dask.dataframe`, `dask.bag`, `dask.delayed`, and xarray on Zarr/NetCDF. + +## Features + +- **Source attribution.** Maps every Dask task-graph layer back to the user + source line that built it — the call site in *your* code, never into + `dask`/`numpy`/`xarray` internals. +- **Deep memory (memray, as a library).** Opt-in per-run allocation tracing, + epoch-rotated and folded to the first line of your code, so the dashboard shows + `job.py:42 build = 12.8 GB` and a per-worker flamegraph / memray-style tree — + you never touch a capture file. +- **Worker-death post-mortem.** A scheduler plugin records the tasks in flight + when a worker vanishes; the collector joins in the chunk metadata and the + allocation lines at the high-water mark to answer *which chunk, which line*. +- **Real-time dashboard.** A Next.js app streaming over WebSocket: live worker + table, a zoomable task stream (global + per-worker), the whole task-graph DAG, + memory-over-time with a click-to-inspect spike explorer, per-layer allocations + over time, and the deep flamegraph. +- **Any scheduler.** `register()` installs worker + scheduler plugins on + `dask.distributed`; `LocalProfiler` hooks the callback API for the threaded, + processes, and synchronous schedulers. +- **Team-friendly.** Every run records the machine (hostname + IP) that opened + it, so a shared collector becomes one place to see everyone's runs. +- **Prometheus + TimescaleDB.** The collector exposes `/metrics` for Grafana and + stores to TimescaleDB (or self-contained SQLite for local use / tests). + +## Installation ```bash -docker compose up -d --build -# dashboard → http://localhost:3000 collector API → http://localhost:8765 +pip install daskgenie ``` -Then point any job at the collector and profile — each `register()` opens a -**run** that appears on the dashboard: - -```python -from distributed import Client, LocalCluster -import daskgenie as dg -import daskgenie.client as genie - -client = Client(LocalCluster(processes=True)) -run_id = genie.register(client, "http://localhost:8765", run_name="nightly ETL") +Optional extras: -with dg.track() as source_map: - result = build_pipeline() -genie.upload_graph("http://localhost:8765", run_id, source_map) -result.compute() +```bash +pip install 'daskgenie[deep]' # memray-backed deep memory profiling +pip install 'daskgenie[collector]' # the FastAPI collector service (server side) +pip install 'daskgenie[examples]' # xarray, zarr, pandas, ... for the examples ``` -The dashboard lists every run (worker/sample/death counts, one-click delete). -Open a run for: +DaskGenie requires Python 3.11 or newer. Deep memory profiling needs memray +(Linux/macOS + CPython); where it isn't importable the profiler silently +degrades to lightweight RSS/managed-memory sampling. -- **Overview** — stats (workers, tasks, deaths, peak RSS), memory-over-time, - and a per-layer task/time breakdown. -- **Post-mortem** — each worker death: suspect task, source line (syntax - highlighted), and the chunk it was holding (`(4000, 4000) float64 = 128 MB`). -- **Timeline** — memory and a task stream on one **top-to-bottom time axis** - (allocation on the horizontal), so a memory spike lines up with the tasks - running at that moment. -- **Task graph** — the real connected task DAG (top-to-bottom), coloured by - layer, death-suspect nodes highlighted; click a node for its source line and - chunk sizes. Falls back to a layer-level view for very large graphs. +## Quick start -### Developing the dashboard - -The dashboard is a Next.js (App Router, TypeScript) app in `web/`. Run the -collector and the dev server separately; the dashboard proxies `/api` to the -collector (`COLLECTOR_URL`, default `http://127.0.0.1:8765`): +Bring up the dashboard stack — the **collector** (API + `/metrics` + WebSocket), +**TimescaleDB**, and the **Next.js dashboard** — with Docker: ```bash -uv run python -m daskgenie.collector --port 8765 # terminal 1 -cd web && npm install && npm run dev # terminal 2 → http://localhost:3000 +docker compose up -d --build +# dashboard → http://localhost:3000 collector API → http://localhost:8765 ``` -## Quickstart (GraphCapture) +Then point a job at the collector. Each `register()` opens a **run** that appears +live on the dashboard: -Requires Python 3.11+ and [`uv`](https://docs.astral.sh/uv/). +```python +from distributed import Client, LocalCluster +import daskgenie as dg +import daskgenie.client as genie -```bash -uv sync --group dev -uv run --extra demo python examples/graph_source_map.py -``` +client = Client(LocalCluster(processes=True)) +run_id = genie.register(client, "http://localhost:8765", run_name="nightly ETL", deep=True) -This prints the layer → source-location map `GraphCapture` recovered from a -pipeline (chunked read → rechunk-merge → per-block op): +with dg.track() as source_map: + result = build_pipeline() # your dask work +genie.upload_graph("http://localhost:8765", run_id, source_map, collection=result) +result.compute() ``` -layer source ----------------------------------------------------------------------------------------------------- -random_sample-... examples/graph_source_map.py:15 x = da.random.random(...) -rechunk-merge-... examples/graph_source_map.py:16 y = x.rechunk((8000, 8000)) -lambda-... examples/graph_source_map.py:17 return y.map_blocks(...).sum() -``` -Each layer name is the exact key Dask uses internally; each source location -points at the *user's* call site, not into `dask`/`numpy`/`xarray` internals. -See [`examples/`](./examples) for the full set of runnable examples. +Open the run and explore it as it runs: + +- **Overview** — live stats, memory-over-time, the hottest allocation line. +- **Timeline** — a large, zoomable memory chart; click any point to see what was + running and which source lines were allocating at that instant, plus a stacked + per-layer allocation timeline. +- **Workers** — a live, native-Dask-style table (RSS vs limit, CPU, threads, + executing/ready). +- **Task stream** — global + per-worker task lanes on a zoomable time axis. +- **Graph** — the real connected task DAG (canvas for large graphs), coloured by + layer, death-suspect nodes highlighted; click a node for its source and chunks. +- **Memory** — the deep view: allocation flamegraph, peak bytes by source line, + and peak memory by task. +- **Post-mortem** — each worker death: the allocation lines at the high-water + mark, the suspect task, its source line, and the chunk it was holding. -## Any scheduler, not just distributed +## Local schedulers -On `dask.distributed`, `register()` installs worker + scheduler plugins. For the -**local** schedulers (`scheduler="threads"|"synchronous"|"processes"` — the -default for bare dask arrays/dataframes) use `LocalProfiler`, which hooks Dask's -callback API to sample memory and per-task output chunks: +For the non-distributed schedulers (`.compute(scheduler="threads"|"processes"| +"synchronous")`, the default for bare dask collections) use `LocalProfiler`, +which hooks Dask's callback API instead of installing cluster plugins: ```python import daskgenie as dg @@ -119,116 +114,92 @@ with dg.track() as source_map: result = build_pipeline() with dg.LocalProfiler("http://localhost:8765", run_name="threaded job", - source_map=source_map) as prof: + source_map=source_map, collection=result, deep=True) as prof: result.compute(scheduler="threads") # prof.run_id shows up in the dashboard like any other run ``` -### Using it in your own code - -```python -import daskgenie as dg - -with dg.track() as layer_map: - ds = open_dataset(...) # your pipeline - result = ds.rechunk(...).compute() - -for layer_name, loc in layer_map.items(): - print(layer_name, "->", f"{loc.filename}:{loc.lineno}", loc.code_snippet) -``` - -Or decorate a function you want tracked instead of wrapping a block: - -```python -@dg.watch -def build_pipeline(): - ... -``` - -Results from `track()` and `@watch` accumulate into the same map; read it -anytime with `dg.get_layer_map()`. - -## Profiling a live cluster (WorkerPlugin + Collector) - -Start the collector (SQLite-backed, serves ingest + `/metrics` + query API): - -```bash -uv sync --group dev --extra collector -uv run python -m daskgenie.collector --port 8765 # --db PATH to persist -``` - -Then, in your job, install the profiler plugin on the cluster and (optionally) -push the source map so memory lines up with the code that produced it: - -```python -from distributed import Client, LocalCluster -import daskgenie as dg -import daskgenie.client as genie - -cluster = LocalCluster(n_workers=4, processes=True) -client = Client(cluster) - -run_id = genie.register(client, "http://127.0.0.1:8765") # opens a run - -with dg.track() as layer_map: - result = build_pipeline() # your xarray-on-Zarr work -genie.upload_graph("http://127.0.0.1:8765", run_id, layer_map) - -result.compute() -``` - -Read it back with plain HTTP (everything is scoped to the `run_id`): - -```bash -curl 'http://127.0.0.1:8765/api/runs' # list runs -curl "http://127.0.0.1:8765/api/runs/$RUN/timeline" # memory over time -curl "http://127.0.0.1:8765/api/runs/$RUN/deaths" # worker-death post-mortems -curl 'http://127.0.0.1:8765/metrics' # Prometheus: point Grafana here -``` - -The `/metrics` endpoint exposes per-worker RSS and managed-memory gauges plus -sample/death counters, so existing Grafana setups get value without the custom -UI. Every payload between the plugins and collector is a versioned pydantic -model (`daskgenie.common.schemas`); the plugins never import collector code. - ## The post-mortem: which chunk killed this worker -When `register()` is active, a scheduler plugin watches for worker deaths. On a -death it records the tasks that were in flight on that worker as suspects, and -the collector joins in the chunk metadata the worker had already reported — so -the post-mortem tells you the worker, the suspect task, the chunk it was -holding, and (via the source map) the line that produced it. - -See it end to end on a LocalCluster whose worker is really OOM-killed, then open -the run's **Post-mortem** tab in the dashboard: +With `register()` active, a scheduler plugin watches for worker deaths. On a +death it records the tasks that were in flight; the collector joins in the chunk +metadata the worker had already reported and (with `deep=True`) the allocation +lines at the high-water mark. See it end to end on a `LocalCluster` whose worker +is really OOM-killed: ```bash docker compose up -d --build -uv run --extra demo python examples/distributed_oom.py +uv run --extra demo --extra deep python examples/deep_oom.py ``` -OOM vs. clean shutdown is a heuristic, not a certainty: the scheduler doesn't -tell a plugin *why* a worker left, so DaskGenie flags a *suspected* OOM only -when tasks were in flight at an unexpected removal, and never over-claims. +OOM vs. clean shutdown is a heuristic: the scheduler doesn't tell a plugin *why* +a worker left, so DaskGenie flags a *suspected* OOM only when tasks were in +flight at an unexpected removal, and never over-claims. + +## Configuration + +Everything is configured through environment variables: + +| Variable | Component | Purpose | +| --- | --- | --- | +| `DASKGENIE_DSN` | collector | Postgres/TimescaleDB DSN; selects the Timescale backend (default in Docker). | +| `DASKGENIE_DB` | collector | SQLite path (or `:memory:`) when no DSN is set. | +| `DASKGENIE_HOST` / `DASKGENIE_PORT` | collector | Bind address (default `127.0.0.1:8765`). | +| `COLLECTOR_URL` | dashboard | Where the Next.js server proxies `/api` (e.g. `http://collector:8765`). | +| `NEXT_PUBLIC_COLLECTOR_WS` | dashboard | WebSocket base the browser connects to (default `ws://:8765`). | + +The `register()` and `LocalProfiler` calls take `deep=`, `sample_interval=`, +`flush_interval=`, and `deep_epoch_seconds=` to trade overhead for resolution. + +## Notes and limitations + +- **Source attribution** is strongest for `dask.array` and `dask.delayed`. For + `dask.dataframe` (which builds graphs through dask-expr) and xarray, the heavy + work runs inside library/C code, so per-line allocations fold to framework + frames — the graph, memory, per-layer and flamegraph views still work, but + layer→line mapping is sparse there. +- **memray** is Linux/macOS + CPython only, and a single tracker runs per + process; deep mode is opt-in and costs roughly 1.5–2× runtime. +- On a **hard OOM kill** the worker's in-flight memray epoch can die before it + flushes, so a specific post-mortem's line attribution is best-effort — the + Memory tab remains the reliable place to see the culprit line. +- The **OOM label** is a heuristic, not a certainty (see the post-mortem note). +- **TimescaleDB** is the default store in Docker; SQLite is the zero-setup + backend used locally and by the test suite. + +## Examples + +Runnable scripts covering the breadth of Dask live in +[`examples/`](./examples) — distributed OOMs, the deep-memory demo, a big +minutes-long pipeline, a self-limiting crash, and one per collection type +(`dask.delayed`, `dask.dataframe`, `dask.bag`, xarray on Zarr and NetCDF). See +[`examples/README.md`](./examples/README.md). ## Development ```bash -uv sync --group dev +uv sync --group dev --extra collector --extra deep uv run pytest uv run ruff check . uv run ruff format . uv run mypy src/ + +# collector + dashboard separately (dashboard proxies /api to the collector) +uv run python -m daskgenie.collector --port 8765 # terminal 1 +cd web && npm install && npm run dev # terminal 2 → :3000 ``` ## How source attribution works `HighLevelGraph.from_collections` is the one classmethod almost every Dask -collection operation (array, dataframe, and by extension xarray, since it -wraps dask arrays) calls to register a new named graph layer. `track()` -patches it for the duration of the `with` block: each time a new layer name -appears, it walks the call stack outward until it finds the first frame that -isn't inside `dask`/`distributed`/`xarray`/`numpy`/`zarr`/`daskgenie` or a -`site-packages` install — that's the user's call site. The library-path -filter is configurable via `track(extra_library_paths=[...])` for teams with -their own internal wrapper libraries. +collection operation calls to register a new named graph layer. `track()` +patches it for the duration of the `with` block: each time a new layer appears, +it walks the call stack outward until it finds the first frame that isn't inside +`dask`/`distributed`/`xarray`/`numpy`/`zarr`/`daskgenie` or a `site-packages` +install — that's your call site. The deep memory engine reuses the same +library-path filter to fold memray stacks to the first user frame. The +library-path filter is configurable via `track(extra_library_paths=[...])`. + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/examples/README.md b/examples/README.md index 90d395c..4c604b2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,15 @@ | `graph_source_map.py` | nothing | Source attribution only — layer → the line that built it. | | `local_scheduler.py` | running collector | Memory + chunks + graph for a **local** scheduler (threaded/synchronous/processes). | | `distributed_oom.py` | running collector | A real worker OOM and its post-mortem on `dask.distributed`. | +| `deep_oom.py` | running collector + `deep` extra | Deep memory (memray): the **source line** that allocated the OOM array, live Workers/Task-stream/Memory tabs. | +| `big_pipeline_oom.py` | running collector + `deep` extra | A realistic **minutes-long** multi-stage pipeline (FFT + matmul), a **large task graph** (~4.5k nodes), streamed live, ending in a real worker OOM the Memory tab attributes to `upsample_block`. | +| `threaded_big.py` | running collector + `deep` extra | **Threaded scheduler** (no workers): a pipeline that *hoards* every result in memory instead of spilling to Zarr — the RSS staircase + the accumulation line, the "should have gone to disk" signature. | +| `threaded_crash.py` | running collector + `deep` extra | Threaded scheduler that **actually crashes** (self-limiting `MemoryError`): the memory curve climbing to the wall and the line that got it there. | +| `dask_delayed.py` | running collector + `examples` + `deep` extras | **dask.delayed** custom ETL DAG on a distributed cluster — per-line memory on `transform_shard`. | +| `dask_dataframe.py` | running collector + `examples` + `deep` extras | **dask.dataframe** groupby-aggregation + shuffle (`set_index` rolling) on a synthetic timeseries. | +| `dask_bag.py` | running collector + `examples` + `deep` extras | **dask.bag** record/JSON aggregation on the **processes** scheduler (per-process memory). | +| `xarray_zarr.py` | running collector + `examples` + `deep` extras | **xarray on Zarr**: a chunked cube streamed from a Zarr store → climatology + anomaly (bounded memory, the read-don't-hoard pattern). | +| `xarray_netcdf.py` | running collector + `examples` + `deep` extras | **xarray on NetCDF** (HDF5/h5netcdf): open with Dask chunks, rolling-mean detrend + reduce. | ## Setup @@ -20,8 +29,23 @@ Then run an example and open the dashboard at http://localhost:3000: ```bash uv run --extra demo python examples/local_scheduler.py uv run --extra demo python examples/distributed_oom.py +uv run --extra demo --extra deep python examples/deep_oom.py +uv run --extra demo --extra deep python examples/big_pipeline_oom.py # ~2-4 min, big graph, real OOM +uv run --extra demo --extra deep python examples/threaded_big.py # threaded, memory-hoard demo + +# Across Dask's collections (need the `examples` extra: xarray, zarr, pandas, ...) +uv run --extra examples --extra deep python examples/dask_delayed.py +uv run --extra examples --extra deep python examples/dask_dataframe.py +uv run --extra examples --extra deep python examples/dask_bag.py +uv run --extra examples --extra deep python examples/xarray_zarr.py +uv run --extra examples --extra deep python examples/xarray_netcdf.py ``` +> Source-line attribution is strongest for `dask.array` and `dask.delayed`. For +> `dask.dataframe` (dask-expr) and xarray the heavy work runs inside library C +> code, so allocations fold to framework frames — the **flamegraph** still shows +> the full call tree, and the graph/memory views work throughout. + (No Docker? Run the collector directly with `uv run python -m daskgenie.collector --port 8765` and the dashboard dev server from `web/` — see the top-level README.) diff --git a/examples/big_pipeline_oom.py b/examples/big_pipeline_oom.py new file mode 100644 index 0000000..ca75499 --- /dev/null +++ b/examples/big_pipeline_oom.py @@ -0,0 +1,192 @@ +"""A realistic, minutes-long Dask pipeline that builds a large task graph, does +genuine heavy per-block compute, and then dies from a real out-of-memory on a +worker — the full DaskGenie story end to end. + +It mimics an image/geospatial processing pipeline over a big float32 field: + + 1. ingest random scene, chunked (big base graph) + 2. normalise elementwise (x - mean) / std (per-block) + 3. smooth overlapping stencil (map_overlap) (halo exchange → edges) + 4. spectral per-block FFT magnitude (CPU heavy) + 5. refine ×N matmul-based diffusion + rechunk (burns minutes, huge graph) + 6. upsample naive super-resolution (np.kron) (THE OOM — one block + explodes to a multi-GB float64 array, past the worker limit) + +Stages 1-5 run for a couple of minutes and stream live to the dashboard +(Workers / Task stream / Memory update in real time). Stage 6 then OOM-kills a +worker on a single, nameable line, so the Post-mortem and the deep Memory tab +point straight at ``big_pipeline_oom.py: upsample_block``. + +Start the stack first (``docker compose up -d``), then: + + uv run --extra demo --extra deep python examples/big_pipeline_oom.py + +Tunables (env): DG_N (field size), DG_CHUNK, DG_ITERS, DG_WORKERS, DG_MEMLIMIT. +Defaults run ~2-4 min and reliably OOM with a 1900MB worker limit. +""" + +from __future__ import annotations + +import os +import time + +import numpy as np +from distributed import Client, LocalCluster, wait + +import daskgenie as dg +import daskgenie.client as genie + +COLLECTOR = os.environ.get("DG_COLLECTOR", "http://localhost:8765") +N = int(os.environ.get("DG_N", "14000")) +CHUNK = int(os.environ.get("DG_CHUNK", "1400")) +ITERS = int(os.environ.get("DG_ITERS", "3")) +REPEAT = int(os.environ.get("DG_REPEAT", "8")) # inner matmuls per diffuse — runtime knob +WORKERS = int(os.environ.get("DG_WORKERS", "4")) +# Headroom so the refine loop runs clean; the upsample is the intended OOM. +MEMLIMIT = os.environ.get("DG_MEMLIMIT", "2600MB") +UPSAMPLE = int(os.environ.get("DG_UPSAMPLE", "11")) # Kron factor → ~1.9 GB per tile + + +# -- per-block compute kernels (real work, each a nameable source line) -------- + + +def normalise_block(block: np.ndarray) -> np.ndarray: + # standardise the tile; cheap, elementwise + mu = block.mean() + sd = block.std() + 1e-6 + return ((block - mu) / sd).astype(np.float32) + + +def spectral_block(block: np.ndarray) -> np.ndarray: + # 2-D FFT magnitude — genuinely CPU-heavy, keeps workers busy + spec = np.fft.rfft2(block) + mag = np.abs(spec).astype(np.float32) + # pad back to the block's shape so the graph stays rectangular + out = np.zeros_like(block) + out[:, : mag.shape[1]] = mag[:, : out.shape[1]] + return out + + +def diffuse_block(block: np.ndarray) -> np.ndarray: + # matmul-based smoothing: an (n x n) gaussian kernel applied REPEAT times. + # O(n^3) per pass — the main time sink of the refine loop. Single-sided so it + # stays valid for non-square edge tiles after a rechunk; REPEAT (not more + # tasks) is the runtime knob, keeping the graph a readable size. + n = block.shape[0] + kernel = np.exp(-((np.arange(n)[:, None] - np.arange(n)[None, :]) ** 2) / (2 * 64.0)) + kernel = (kernel / kernel.sum(axis=1, keepdims=True)).astype(np.float32) + out = block + for _ in range(REPEAT): + out = (kernel @ out).astype(np.float32) + return out + + +def upsample_block(block: np.ndarray) -> np.ndarray: + # Naive super-resolution: Kronecker-upsample each tile. For a 1400x1400 tile + # at factor 11 this materialises a 15400x15400 float64 array (~1.9 GB) on ONE + # task — the careless allocation the deep Memory tab and post-mortem name. + hires = np.kron(block.astype(np.float64), np.ones((UPSAMPLE, UPSAMPLE))) # ~1.9 GB + # Hold + touch the array for ~1.2s of real work so a deep-memory epoch closes + # and flushes this multi-GB high-water mark *before* the fatal second buffer + # below tips the worker over its limit (a hard OOM kill is faster than an + # epoch, so without this the killing line dies unrecorded). + t0 = time.time() + acc = 0.0 + while time.time() - t0 < 1.2: + acc += float(hires.sum()) + # now blow past the limit: a second equally-huge buffer forces the OOM + killer = np.kron(block.astype(np.float64), np.ones((UPSAMPLE, UPSAMPLE))) # +1.9 GB → OOM + return ((hires + killer)[::UPSAMPLE, ::UPSAMPLE] + acc * 0.0).astype(np.float32) + + +def main() -> None: + import dask.array as da + + cluster = LocalCluster( + n_workers=WORKERS, + threads_per_worker=1, + processes=True, + memory_limit=MEMLIMIT, + dashboard_address=":0", + ) + client = Client(cluster) + print(f"cluster: {WORKERS} workers x {MEMLIMIT}; field {N}x{N} float32, chunk {CHUNK}") + + try: + run_id = genie.register( + client, + COLLECTOR, + run_name=f"big pipeline {N}x{N} (OOM)", + flush_interval=0.25, + deep=True, + deep_epoch_seconds=1.0, + ) + print(f"run_id = {run_id} — open the dashboard now and watch it live") + + with dg.track() as source_map: + # 1. ingest — a big chunked float32 field + x = da.random.random((N, N), chunks=(CHUNK, CHUNK)).astype(np.float32) + + # 2. normalise (elementwise, per-block) + x = x.map_blocks(normalise_block, dtype=np.float32) + + # 3. smooth — overlapping stencil, adds halo-exchange edges to the graph + x = x.map_overlap( + lambda b: (b + np.roll(b, 1, 0) + np.roll(b, -1, 0)) / 3.0, + depth=1, + boundary="reflect", + dtype=np.float32, + ) + + # 4. spectral features (CPU heavy) + x = x.map_blocks(spectral_block, dtype=np.float32) + + # 5. refine loop — matmul diffusion + a rechunk each pass. This is + # where the minutes go and the graph balloons. + for i in range(ITERS): + x = x.map_blocks(diffuse_block, dtype=np.float32) + # alternate the chunking so successive passes must reshuffle + new_chunk = CHUNK if i % 2 == 0 else CHUNK + 500 + x = x.rechunk((new_chunk, new_chunk)) + x = (x - x.mean()) / (x.std() + 1e-6) + + # Upload the FULL refined-field graph (stages 1-5, thousands of tasks) + # so the Graph tab shows the real multi-stage DAG — not just the small + # post-persist tail. The upsample line is added to the source map below + # so the Memory/Post-mortem tabs can still name it. + with dg.track() as source_map2: + # 6. the OOM: naive super-resolution upsample + result = x.map_blocks(upsample_block, dtype=np.float32).sum() + source_map.update(source_map2) + dg.upload_graph(COLLECTOR, run_id, source_map, collection=x) + + # Persist the refined field so stages 1-5 actually run (and stream) before + # the OOM — this is the "couple of minutes of real work" part. + print("running stages 1-5 (this takes a few minutes)...") + t0 = time.time() + x = client.persist(x) + wait(x) + print(f"refined field ready in {time.time() - t0:.0f}s; now the upsample OOM...") + + # rebuild the OOM step on the now-persisted field + result = x.map_blocks(upsample_block, dtype=np.float32).sum() + + try: + client.compute(result).result(timeout=180) + print("no OOM — try a smaller DG_MEMLIMIT or larger DG_CHUNK") + except Exception as exc: # noqa: BLE001 - expected KilledWorker + print(f"worker OOM-killed as expected: {type(exc).__name__}") + + print( + f"\ndone — run {run_id!r}:\n" + " • Memory tab → upsample_block line at multi-GB peak\n" + " • Post-mortem → the suspect task, chunk, and (timing permitting) the line\n" + " • Graph tab → the full multi-thousand-node task DAG" + ) + finally: + client.close() + cluster.close() + + +if __name__ == "__main__": + main() diff --git a/examples/dask_bag.py b/examples/dask_bag.py new file mode 100644 index 0000000..6811ddf --- /dev/null +++ b/examples/dask_bag.py @@ -0,0 +1,55 @@ +"""Profile a **dask.bag** pipeline (processes scheduler). + +Bags are Dask's tool for messy, record-oriented data (JSON logs, text). This +simulates a stream of event records, parses/filters/aggregates them (a word/tag +frequency count via ``foldby``), and profiles it on the multiprocessing +scheduler — so DaskGenie shows per-process memory, not just threads. + +Needs the ``examples`` + ``deep`` extras. Start the stack, then: + + uv run --extra examples --extra deep python examples/dask_bag.py +""" + +from __future__ import annotations + +import os +import random + +import daskgenie as dg + +COLLECTOR = os.environ.get("DG_COLLECTOR", "http://localhost:8765") +N = int(os.environ.get("DG_N", "4000000")) +PARTS = int(os.environ.get("DG_PARTS", "32")) + +TAGS = ["error", "warn", "info", "debug", "trace", "fatal", "audit", "metric"] + + +def make_record(i: int) -> dict: + rng = random.Random(i) + return {"id": i, "tag": rng.choice(TAGS), "value": rng.random(), "ok": rng.random() > 0.2} + + +def main() -> None: + import dask.bag as db + + with dg.track() as source_map: + bag = db.from_sequence(range(N), npartitions=PARTS).map(make_record) + kept = bag.filter(lambda r: r["ok"]) + # frequency of each tag among kept records + counts = kept.foldby("tag", lambda acc, _r: acc + 1, 0, lambda a, b: a + b) + result = counts + + with dg.LocalProfiler( + COLLECTOR, + run_name="dask.bag log aggregation", + source_map=source_map, + deep=True, + deep_epoch_seconds=2.0, + ) as prof: + print(f"run {prof.run_id}: folding {N:,} records over {PARTS} partitions...") + out = dict(result.compute(scheduler="processes", num_workers=4)) + print(f"done — tag counts: {out}\nopen the dashboard run {prof.run_id!r}") + + +if __name__ == "__main__": + main() diff --git a/examples/dask_dataframe.py b/examples/dask_dataframe.py new file mode 100644 index 0000000..8d0a4a2 --- /dev/null +++ b/examples/dask_dataframe.py @@ -0,0 +1,62 @@ +"""Profile a **dask.dataframe** pipeline on a distributed cluster. + +Uses Dask's built-in synthetic timeseries (no external data), then does the +memory-heavy things dataframes are known for: a groupby-aggregation and a +shuffle-y ``set_index``. DaskGenie shows the partition memory over time and the +per-layer allocation timeline (groupby vs shuffle). + +Start the stack (``docker compose up -d``), then: + + uv run --extra examples --extra deep python examples/dask_dataframe.py +""" + +from __future__ import annotations + +import os + +from distributed import Client, LocalCluster + +import daskgenie as dg +import daskgenie.client as genie + +COLLECTOR = os.environ.get("DG_COLLECTOR", "http://localhost:8765") +DAYS = int(os.environ.get("DG_DAYS", "120")) # ~ size of the synthetic frame + + +def main() -> None: + import dask.dataframe as dd + import dask.datasets + + cluster = LocalCluster( + n_workers=4, threads_per_worker=2, processes=True, dashboard_address=":0" + ) + client = Client(cluster) + try: + run_id = genie.register(client, COLLECTOR, run_name="dask.dataframe groupby", deep=True) + + with dg.track() as source_map: + df = dask.datasets.timeseries( + start="2020-01-01", + end=f"2020-{1 + DAYS // 30:02d}-01", + freq="100ms", + partition_freq="1d", + dtypes={"x": float, "y": float, "id": int}, + ) + df = df.assign(z=(df.x * df.y).abs()) + # groupby-aggregation (hash aggregation memory) + agg = df.groupby("id").agg({"x": "mean", "y": "std", "z": "sum"}) + # a rolling window over a set_index (shuffle) branch + rolled = df.set_index("id").z.rolling(50).mean() + result = agg.z.sum() + dd.to_numeric(rolled.fillna(0)).sum() + dg.upload_graph(COLLECTOR, run_id, source_map, collection=result) + + print(f"run {run_id}: computing groupby + rolling over the frame...") + val = result.compute() + print(f"done — result={float(val):.3e}; open the dashboard run {run_id!r}") + finally: + client.close() + cluster.close() + + +if __name__ == "__main__": + main() diff --git a/examples/dask_delayed.py b/examples/dask_delayed.py new file mode 100644 index 0000000..5b7f5dd --- /dev/null +++ b/examples/dask_delayed.py @@ -0,0 +1,77 @@ +"""Profile a **dask.delayed** pipeline on a distributed cluster. + +``@delayed`` builds a task graph out of ordinary Python functions — the classic +"custom ETL DAG" use of Dask. DaskGenie captures the graph, per-task memory, and +(with ``deep=True``) the source line behind each allocation, exactly as it does +for arrays. + +Start the stack (``docker compose up -d``), then: + + uv run --extra examples --extra deep python examples/dask_delayed.py + +Open the run: the **Graph** tab shows the delayed DAG (load → transform → +combine), **Memory** attributes bytes to ``transform_shard``. +""" + +from __future__ import annotations + +import os + +import numpy as np +from dask import delayed +from distributed import Client, LocalCluster + +import daskgenie as dg +import daskgenie.client as genie + +COLLECTOR = os.environ.get("DG_COLLECTOR", "http://localhost:8765") +SHARDS = int(os.environ.get("DG_SHARDS", "24")) +SIZE = int(os.environ.get("DG_SIZE", "2000")) + + +@delayed +def load_shard(i: int) -> np.ndarray: + # stand-in for reading shard i off disk / object store + rng = np.random.default_rng(i) + return rng.random((SIZE, SIZE), dtype=np.float64) + + +@delayed +def transform_shard(a: np.ndarray) -> np.ndarray: + # per-shard feature transform — where the memory goes + return np.tanh(a @ a.T) / (a.std() + 1e-9) + + +@delayed +def reduce_shard(a: np.ndarray) -> float: + return float(a.sum()) + + +@delayed +def combine(parts: list[float]) -> float: + return float(np.sum(parts)) + + +def main() -> None: + cluster = LocalCluster( + n_workers=4, threads_per_worker=1, processes=True, dashboard_address=":0" + ) + client = Client(cluster) + try: + run_id = genie.register(client, COLLECTOR, run_name="dask.delayed ETL", deep=True) + + with dg.track() as source_map: + parts = [reduce_shard(transform_shard(load_shard(i))) for i in range(SHARDS)] + total = combine(parts) + dg.upload_graph(COLLECTOR, run_id, source_map, collection=total) + + print(f"run {run_id}: computing {SHARDS} delayed shards...") + result = total.compute() + print(f"done — result={result:.3e}; open the dashboard run {run_id!r}") + finally: + client.close() + cluster.close() + + +if __name__ == "__main__": + main() diff --git a/examples/deep_oom.py b/examples/deep_oom.py new file mode 100644 index 0000000..8174750 --- /dev/null +++ b/examples/deep_oom.py @@ -0,0 +1,72 @@ +"""Deep memory profiling end to end: run a pipeline that allocates a huge array +on a line we can name, with the memray engine on, then read the *source line* +that dominated memory — not just "a chunk was resident". + +Start the stack first (``docker compose up -d``), then: + + uv run --extra demo --extra deep python examples/deep_oom.py + +Open http://localhost:3000, pick the run, and watch it live: + +- **Workers** / **Task stream** update in real time as the job runs. +- **Memory** shows the allocation flamegraph and the per-source-line table — + ``deep_oom.py: build_monster ~2 GB`` at the top. +- **Post-mortem** (if the worker is OOM-killed) names that allocation line as + the cause, above the input-chunk view. +""" + +from __future__ import annotations + +import numpy as np +from distributed import Client, LocalCluster, wait + +import daskgenie as dg +import daskgenie.client as genie + +COLLECTOR = "http://localhost:8765" + + +def build_monster(block: np.ndarray) -> float: + # This is the line that eats the memory — the deep engine attributes the + # high-water mark straight back here (file:line), then to this task/layer. + monster = np.ones((16000, 16000), dtype="float64") # ~2 GB, on THIS line + return float(np.asarray(block).sum() + monster.sum()) + + +def main() -> None: + import dask.array as da + + cluster = LocalCluster(n_workers=2, threads_per_worker=1, processes=True, memory_limit="1500MB") + client = Client(cluster) + try: + # deep=True installs the memray engine on each worker (needs the `deep` + # extra). deep_epoch_seconds bounds how quickly per-line data streams in. + run_id = genie.register( + client, + COLLECTOR, + run_name="deep OOM (memray)", + flush_interval=0.25, + deep=True, + deep_epoch_seconds=2.0, + ) + + with dg.track() as source_map: + x = client.persist(da.ones((8000, 8000), chunks=(4000, 4000))) + wait(x) + result = x.map_blocks(build_monster, dtype="float64").sum() + dg.upload_graph(COLLECTOR, run_id, source_map, collection=result) + + print(f"running with deep memory profiling — run {run_id!r}") + try: + client.compute(result).result(timeout=90) + except Exception as exc: # noqa: BLE001 - expected KilledWorker on OOM + print(f"worker died as expected: {type(exc).__name__}") + + print(f"done — open the dashboard, run {run_id!r}, Memory + Post-mortem tabs") + finally: + client.close() + cluster.close() + + +if __name__ == "__main__": + main() diff --git a/examples/distributed_oom.py b/examples/distributed_oom.py index fc5c87b..027d44d 100644 --- a/examples/distributed_oom.py +++ b/examples/distributed_oom.py @@ -31,9 +31,7 @@ def blowup(block: np.ndarray) -> float: def main() -> None: import dask.array as da - cluster = LocalCluster( - n_workers=2, threads_per_worker=1, processes=True, memory_limit="1500MB" - ) + cluster = LocalCluster(n_workers=2, threads_per_worker=1, processes=True, memory_limit="1500MB") client = Client(cluster) try: run_id = genie.register(client, COLLECTOR, run_name="rechunk OOM", flush_interval=0.1) diff --git a/examples/threaded_big.py b/examples/threaded_big.py new file mode 100644 index 0000000..58ccb86 --- /dev/null +++ b/examples/threaded_big.py @@ -0,0 +1,86 @@ +"""A big **threaded-scheduler** pipeline that shows the other failure mode: not a +single monster allocation, but *holding too much in memory that should have been +spilled to disk (Zarr)*. + +There are no distributed workers here — everything runs in one process on +``scheduler="threads"``, profiled by ``LocalProfiler`` (the Dask callback API). +The pipeline processes a stack of large scenes and — the bug — keeps **every** +processed scene resident in a Python list instead of streaming each to a Zarr +store. Memory climbs step by step; DaskGenie's deep Memory tab attributes the +growth to the exact accumulation line, and the memory-over-time chart shows the +staircase that a to-zarr write would have kept flat. + +Run (start the stack first with ``docker compose up -d``): + + uv run --extra demo --extra deep python examples/threaded_big.py + +Tunables (env): DG_N (scene size), DG_CHUNK, DG_STEPS (how many scenes to hoard), +DG_THREADS. Defaults hold ~4-5 GB and run ~2 min. Lower DG_STEPS if your machine +has less RAM — the point is the *shape* of the memory curve, not a hard crash. +""" + +from __future__ import annotations + +import os +import time + +import numpy as np + +import daskgenie as dg + +COLLECTOR = os.environ.get("DG_COLLECTOR", "http://localhost:8765") +N = int(os.environ.get("DG_N", "8000")) +CHUNK = int(os.environ.get("DG_CHUNK", "1000")) +STEPS = int(os.environ.get("DG_STEPS", "18")) +THREADS = int(os.environ.get("DG_THREADS", "4")) + + +def process_scene(block: np.ndarray) -> np.ndarray: + # genuine per-tile work: normalise, FFT magnitude, a matmul smooth + n = block.shape[0] + b = (block - block.mean()) / (block.std() + 1e-6) + spec = np.abs(np.fft.rfft2(b)).astype(np.float32) + out = np.zeros_like(b, dtype=np.float32) + out[:, : spec.shape[1]] = spec[:, : out.shape[1]] + kernel = np.exp(-((np.arange(n)[:, None] - np.arange(n)[None, :]) ** 2) / (2 * 64.0)) + kernel = (kernel / kernel.sum(axis=1, keepdims=True)).astype(np.float32) + return (kernel @ out).astype(np.float32) + + +def main() -> None: + import dask.array as da + + print(f"threaded scheduler: {THREADS} threads; {STEPS} scenes of {N}x{N} float32") + print("BUG on purpose: every processed scene is kept resident instead of spilled to Zarr\n") + + hoarded: list[np.ndarray] = [] # <-- the leak: everything stays in memory + + with dg.LocalProfiler( + COLLECTOR, + run_name=f"threaded hoard {STEPS}x{N}", + deep=True, + deep_epoch_seconds=1.5, + ) as prof: + print(f"run_id = {prof.run_id} — open the dashboard and watch memory climb\n") + for step in range(STEPS): + with dg.track(): + scene = da.random.random((N, N), chunks=(CHUNK, CHUNK)).astype(np.float32) + processed = scene.map_blocks(process_scene, dtype=np.float32) + # RIGHT way (kept for contrast): stream straight to disk, stay flat + # processed.to_zarr(f"/tmp/scenes/{step}.zarr", overwrite=True) + # WRONG way (what this script does): materialise and hoard it + result = processed.compute(scheduler="threads", num_workers=THREADS) + hoarded.append(result) # the line that holds ~256 MB per step forever + held_gb = sum(a.nbytes for a in hoarded) / 1e9 + print(f" step {step + 1:2d}/{STEPS} holding {held_gb:5.2f} GB resident") + time.sleep(0.2) + + print( + f"\ndone — run {prof.run_id!r}. Memory tab: the hoard line at the top of the\n" + "per-source-line table; Overview: a rising staircase instead of a flat line —\n" + "the signature of data that should have gone to a Zarr store, not a Python list." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/threaded_crash.py b/examples/threaded_crash.py new file mode 100644 index 0000000..2d9c379 --- /dev/null +++ b/examples/threaded_crash.py @@ -0,0 +1,93 @@ +"""A threaded-scheduler pipeline that ACTUALLY CRASHES from memory exhaustion. + +Unlike ``threaded_big.py`` (which hoards a bounded amount to show the staircase), +this one grows resident memory **without bound** until the process dies — a real +``MemoryError`` (or an OS OOM-kill). It's the "my script just died and I don't +know why" scenario: DaskGenie's memory-over-time chart shows the climb right up +to the ceiling, and the deep Memory tab names the line doing the allocating. + +This is *self-limiting*: it grows resident memory to a ceiling (default 55% of +this machine's RAM), then attempts one allocation larger than the memory that's +left — which raises a real ``MemoryError`` and crashes the Python process, +WITHOUT dragging the whole machine (or WSL) down with it. That's the honest "my +job died of memory" event, safely. + +Run (start the stack first with ``docker compose up -d``): + + uv run --extra demo --extra deep python examples/threaded_crash.py + +Because the sampler flushes every second, the memory curve and the deep +allocation line are already in the collector by the time the process dies — open +the run and you'll see exactly how big it got and which line got it there. + +Tunables (env): DG_STEP_MB (per-step MiB, default 1024), DG_N/DG_CHUNK (tile), +DG_MAX_GB (hoard ceiling; default ~55% of RAM), DG_THREADS. +""" + +from __future__ import annotations + +import os + +import numpy as np +import psutil + +import daskgenie as dg + +COLLECTOR = os.environ.get("DG_COLLECTOR", "http://localhost:8765") +STEP_MB = int(os.environ.get("DG_STEP_MB", "1024")) # ~1 GiB added per step +N = int(os.environ.get("DG_N", "6000")) +CHUNK = int(os.environ.get("DG_CHUNK", "1000")) +THREADS = int(os.environ.get("DG_THREADS", "4")) +# Ceiling for the hoard, defaulting to a safe fraction of physical RAM so we +# crash *this process*, not the machine. Override with DG_MAX_GB. +_DEFAULT_MAX_GB = round(psutil.virtual_memory().total * 0.55 / 1e9, 1) +MAX_GB = float(os.environ.get("DG_MAX_GB", str(_DEFAULT_MAX_GB))) + + +def heavy_block(block: np.ndarray) -> np.ndarray: + # real per-tile compute so this isn't just np.ones — normalise + smooth + b = (block - block.mean()) / (block.std() + 1e-6) + return np.tanh(b).astype(np.float32) + + +def main() -> None: + import dask.array as da + + # one float64 array of this many elements ~ STEP_MB per step + side = int((STEP_MB * 1024 * 1024 / 8) ** 0.5) + leak: list[np.ndarray] = [] # never freed → the runaway growth + + print(f"threaded scheduler, {THREADS} threads — growing ~{STEP_MB} MiB/step") + print(f"will crash (MemoryError) once it holds ~{MAX_GB} GB (DG_MAX_GB to change)\n") + + with dg.LocalProfiler( + COLLECTOR, run_name="threaded CRASH (OOM)", deep=True, deep_epoch_seconds=1.0 + ) as prof: + print(f"run_id = {prof.run_id} — open the dashboard and watch it climb to the wall\n") + step = 0 + while True: + step += 1 + with dg.track(): + scene = da.random.random((N, N), chunks=(CHUNK, CHUNK)).astype(np.float32) + _ = ( + scene.map_blocks(heavy_block, dtype=np.float32) + .sum() + .compute(scheduler="threads", num_workers=THREADS) + ) + # THE LEAK: a big block that never leaves memory. This is the line the + # deep Memory tab will blame for the runaway growth. + leak.append(np.ones((side, side), dtype=np.float64)) + held_gb = sum(a.nbytes for a in leak) / 1e9 + print(f" step {step:3d} holding {held_gb:6.2f} GB resident", flush=True) + + if held_gb >= MAX_GB: + # Tip over the edge: ask for more than the RAM that's left. numpy + # raises MemoryError immediately — a real crash, machine intact. + avail = psutil.virtual_memory().available + boom = int((avail * 1.5) ** 0.5) + 1 + print(f" ceiling reached — allocating past the wall ({avail / 1e9:.1f} GB free)") + _ = np.ones((boom, boom), dtype=np.float64) # -> MemoryError, crashes here + + +if __name__ == "__main__": + main() diff --git a/examples/xarray_netcdf.py b/examples/xarray_netcdf.py new file mode 100644 index 0000000..1bb53bf --- /dev/null +++ b/examples/xarray_netcdf.py @@ -0,0 +1,63 @@ +"""Profile an **xarray-on-NetCDF** pipeline (threaded scheduler). + +Same shape as the Zarr example but over a NetCDF file (HDF5 via ``h5netcdf``) — +the format most climate/ocean data actually ships in. Opens the file with Dask +chunks, computes a rolling-mean smoothing and a reduction, and DaskGenie +attributes the memory to your lines. + +Needs the ``examples`` + ``deep`` extras. Start the stack, then: + + uv run --extra examples --extra deep python examples/xarray_netcdf.py +""" + +from __future__ import annotations + +import os +import tempfile + +import numpy as np +import xarray as xr + +import daskgenie as dg + +COLLECTOR = os.environ.get("DG_COLLECTOR", "http://localhost:8765") +NT = int(os.environ.get("DG_NT", "200")) +NY = int(os.environ.get("DG_NY", "512")) +NX = int(os.environ.get("DG_NX", "1024")) + + +def build_netcdf(path: str) -> None: + ds = xr.DataArray( + np.random.default_rng(1).random((NT, NY, NX), dtype=np.float32), + dims=("time", "lat", "lon"), + name="ssh", + ).to_dataset() + ds.to_netcdf(path, engine="h5netcdf") + + +def main() -> None: + tmp = os.path.join(tempfile.gettempdir(), "daskgenie_field.nc") + print(f"writing a {NT}×{NY}×{NX} NetCDF to {tmp} ...") + build_netcdf(tmp) + + with dg.track() as source_map: + ds = xr.open_dataset(tmp, engine="h5netcdf", chunks={"time": 20}) + smooth = ds.ssh.rolling(time=5, min_periods=1).mean() # temporal smoothing + detrended = ds.ssh - smooth + result = detrended.var(dim="time").mean().data + + with dg.LocalProfiler( + COLLECTOR, + run_name="xarray + NetCDF smoothing", + source_map=source_map, + collection=result, + deep=True, + deep_epoch_seconds=2.0, + ) as prof: + print(f"run {prof.run_id}: computing detrended variance...") + val = result.compute(scheduler="threads", num_workers=4) + print(f"done — result={float(val):.5f}; open the dashboard run {prof.run_id!r}") + + +if __name__ == "__main__": + main() diff --git a/examples/xarray_zarr.py b/examples/xarray_zarr.py new file mode 100644 index 0000000..2ebb57a --- /dev/null +++ b/examples/xarray_zarr.py @@ -0,0 +1,66 @@ +"""Profile an **xarray-on-Zarr** pipeline (threaded scheduler). + +The canonical geoscience workflow: a chunked data cube (time × lat × lon) stored +in Zarr, opened lazily, and reduced (climatology + anomaly). This is exactly the +"read from disk, don't hoard in RAM" pattern — DaskGenie shows the memory staying +bounded because the cube streams from Zarr, and attributes compute memory to the +anomaly line. + +Needs the ``examples`` + ``deep`` extras. Start the stack, then: + + uv run --extra examples --extra deep python examples/xarray_zarr.py +""" + +from __future__ import annotations + +import os +import tempfile + +import numpy as np +import xarray as xr + +import daskgenie as dg + +COLLECTOR = os.environ.get("DG_COLLECTOR", "http://localhost:8765") +NT = int(os.environ.get("DG_NT", "365")) +NY = int(os.environ.get("DG_NY", "720")) +NX = int(os.environ.get("DG_NX", "1440")) + + +def build_cube_zarr(path: str) -> None: + # a synthetic "daily temperature" cube, chunked and written to Zarr + data = xr.DataArray( + np.random.default_rng(0).random((NT, NY, NX), dtype=np.float32) * 30, + dims=("time", "lat", "lon"), + coords={"time": np.arange(NT)}, + name="t2m", + ).chunk({"time": 30, "lat": NY, "lon": NX}) + data.to_dataset().to_zarr(path, mode="w") + + +def main() -> None: + tmp = os.path.join(tempfile.gettempdir(), "daskgenie_cube.zarr") + print(f"writing a {NT}×{NY}×{NX} cube to {tmp} ...") + build_cube_zarr(tmp) + + with dg.track() as source_map: + ds = xr.open_zarr(tmp) + clim = ds.t2m.mean("time") # climatology + anomaly = ds.t2m - clim # broadcast anomaly (the compute memory) + result = (anomaly**2).mean().data # a dask array scalar + + with dg.LocalProfiler( + COLLECTOR, + run_name="xarray + Zarr anomaly", + source_map=source_map, + collection=result, + deep=True, + deep_epoch_seconds=2.0, + ) as prof: + print(f"run {prof.run_id}: computing the anomaly variance...") + val = result.compute(scheduler="threads", num_workers=4) + print(f"done — result={float(val):.4f}; open the dashboard run {prof.run_id!r}") + + +if __name__ == "__main__": + main() From 6aad1abd31a682a3624d68699b8029686c1052ca Mon Sep 17 00:00:00 2001 From: polymood <36277904+polymood@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:58:37 +0200 Subject: [PATCH 4/4] chore: release workflow, contributing guide, changelog, pre-commit - .github/workflows/workflow-pypi.yml: on a v* tag, build + publish to PyPI via Trusted Publishing (OIDC, no token), push collector + dashboard images to GHCR, and create the GitHub release. - CONTRIBUTING.md: two-trunk model, Conventional Commits, uv/ruff/mypy/pytest, release process. - CHANGELOG.md (Keep a Changelog) and daskgenie.__version__. - .pre-commit-config.yaml running ruff + mypy via uv. --- .github/workflows/workflow-pypi.yml | 86 ++++++++ .pre-commit-config.yaml | 22 ++ CHANGELOG.md | 33 +++ CONTRIBUTING.md | 311 ++++++++++++++++++++++++++++ src/daskgenie/__init__.py | 3 + 5 files changed, 455 insertions(+) create mode 100644 .github/workflows/workflow-pypi.yml create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md diff --git a/.github/workflows/workflow-pypi.yml b/.github/workflows/workflow-pypi.yml new file mode 100644 index 0000000..eb09870 --- /dev/null +++ b/.github/workflows/workflow-pypi.yml @@ -0,0 +1,86 @@ +name: Publish + +# Cut a release by pushing a version tag (e.g. v0.1.0). This builds the Python +# distributions and publishes them to PyPI with a Trusted Publisher (OIDC, no +# stored token), builds and pushes the collector + dashboard images to GHCR, and +# creates the GitHub release. +on: + push: + tags: ["v*"] + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Build sdist + wheel + run: uv build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + pypi: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + # Trusted Publishing requires the id-token permission; no API token is stored. + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + + images: + name: Build & push images (GHCR) + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Collector image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/daskgenie-collector:${{ github.ref_name }} + ghcr.io/${{ github.repository_owner }}/daskgenie-collector:latest + - name: Dashboard image + uses: docker/build-push-action@v6 + with: + context: ./web + file: ./web/Dockerfile + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/daskgenie-web:${{ github.ref_name }} + ghcr.io/${{ github.repository_owner }}/daskgenie-web:latest + + release: + name: GitHub release + needs: [pypi, images] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - name: Create release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..73d835b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +# Run `uv run pre-commit install` once, then these run on every commit. +# Local/system hooks use the project's own pinned tool versions via uv, so +# there's no drift between pre-commit and CI. +repos: + - repo: local + hooks: + - id: ruff-format + name: ruff format + entry: uv run ruff format + language: system + types: [python] + - id: ruff + name: ruff check + entry: uv run ruff check --fix + language: system + types: [python] + - id: mypy + name: mypy + entry: uv run mypy src/ + language: system + types: [python] + pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9013392 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to this project are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +## [0.1.0] + +### Added + +- **Deep memory profiling** with memray driven as a library: per-source-line + high-water-mark attribution and full call stacks for a per-worker flamegraph / + memray-style tree. Opt-in `deep=True`; degrades to lightweight sampling where + memray isn't available. +- **Real-time dashboard** (Next.js) streaming over WebSocket: live Workers table, + global + per-worker task stream, whole-graph canvas DAG, memory-over-time with + a click-to-inspect spike explorer, per-layer allocations over time, and the + deep flamegraph. Collapsible sidebar, in-app modals, Dask warm palette + logo. +- **TimescaleDB** backend (hypertables) as the default collector store behind a + `StoreProtocol`, with SQLite kept for local use and tests. Prometheus + `/metrics` retained. +- **Worker-death post-mortem** joining suspect tasks with chunk metadata and the + allocation lines at the high-water mark. +- **Origin tracking** — each run records the client hostname and IP. +- **Examples** across Dask: distributed/deep OOM demos, a minutes-long pipeline, + a self-limiting crash, and one per collection type (`dask.delayed`, + `dask.dataframe`, `dask.bag`, xarray on Zarr and NetCDF). +- Packaging (PyPI metadata, MIT license), CI, and release workflows. + +[Unreleased]: https://github.com/polymood/DaskGenie/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/polymood/DaskGenie/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..81088c0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,311 @@ +# Contributing to DaskGenie + +Thank you for your interest in contributing to DaskGenie, a memory profiler and +live dashboard for Dask. This document describes how we work together on this +project: how to set up your environment, how to write and commit changes, how +branches are managed, and how releases are tagged. Please read it in full before +opening your first pull request. + +The goal of these guidelines is to keep the codebase consistent, reviewable, and +reliable. Following them helps maintainers review your work quickly and helps +everyone trust that the `main` branch is always in a releasable state. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Development Environment](#development-environment) +- [Code Quality and Tooling](#code-quality-and-tooling) +- [Branching Model](#branching-model) +- [Commit Messages](#commit-messages) +- [Pull Requests](#pull-requests) +- [Testing](#testing) +- [Versioning and Releases](#versioning-and-releases) +- [Reporting Issues](#reporting-issues) + +## Code of Conduct + +We expect all contributors to be respectful and constructive in every +interaction. Disagreement about technical decisions is welcome and healthy, but +personal attacks, harassment, and dismissive behavior are not acceptable. +Maintainers reserve the right to remove comments, commits, or contributors that +violate this principle. + +## Getting Started + +1. Fork the repository on your account, or, if you have write access, create a + branch directly in the main repository. +2. Clone your fork or the repository to your local machine. +3. Set up the development environment as described below. +4. Create a branch for your work following the branching model. +5. Make your changes, ensuring all code quality checks pass. +6. Open a pull request against the `develop` branch. + +## Development Environment + +DaskGenie has two parts: a Python package (`src/daskgenie`) and a Next.js +dashboard (`web/`). The Python side targets Python 3.11 or newer and uses +[uv](https://docs.astral.sh/uv/) for dependency management and virtual +environments. The dashboard uses Node.js 20+. + +### Prerequisites + +- Python 3.11 or newer. +- `uv` installed on your system. +- Node.js 20 or newer (only for dashboard work). +- Git, and Docker if you want to run the full stack locally. + +### Setting up (Python) + +Install the project together with its development and optional dependencies: + +```bash +uv sync --group dev --extra collector --extra deep --extra examples +``` + +Prefix commands with `uv run`, for example: + +```bash +uv run python -c "import daskgenie; print(daskgenie.__version__)" +``` + +Install the pre-commit hooks so that quality checks run automatically before +every commit: + +```bash +uv run pre-commit install +``` + +From this point on, the configured hooks run each time you create a commit. You +can also run them against the whole codebase at any time: + +```bash +uv run pre-commit run --all-files +``` + +### Setting up (dashboard) + +```bash +cd web && npm install +npm run dev # http://localhost:3000, proxies /api to the collector +``` + +Run the collector separately (`uv run python -m daskgenie.collector --port 8765`), +or bring up the whole stack with `docker compose up -d --build`. + +## Code Quality and Tooling + +We hold the codebase to a consistent standard and enforce it automatically. A +pull request will not be merged unless all of the following checks pass. + +### Formatting and Linting + +We use [Ruff](https://docs.astral.sh/ruff/) as both formatter and linter. The +configuration lives in `pyproject.toml`. + +```bash +uv run ruff format . # format in place +uv run ruff check --fix . # lint and apply safe fixes +``` + +For the dashboard, `npm run build` must succeed (it type-checks and lints via +Next.js/ESLint). + +### Static Type Checking + +All Python code must be fully type annotated. We use mypy in strict mode. + +```bash +uv run mypy src/ +``` + +Please do not silence type errors with `# type: ignore` unless it is genuinely +unavoidable. When you do, add a specific error code and a short comment +explaining why. + +### Typing Conventions + +- Prefer precise types over `Any`; reach for `Any` only when there is no + reasonable alternative, and document the reason. +- Use the modern built-in generic syntax (`list[str]`, `dict[str, int]`). +- Use `from __future__ import annotations` where it keeps annotations readable. + +### Running All Checks Locally + +```bash +uv run pre-commit run --all-files +uv run mypy src/ +uv run pytest -m "not integration" +``` + +## Branching Model + +This project uses a two-trunk model with long-lived `main` and `develop` +branches. + +- `main` always reflects the latest released version. Every commit on `main` + corresponds to a tagged release. Nothing is committed to `main` directly except + release merges. +- `develop` is the integration branch where completed work accumulates between + releases. It must always remain in a working, testable state. + +All day-to-day work happens on short-lived branches created from `develop` and +merged back into `develop` through pull requests. A release is performed by +merging `develop` into `main` and tagging that commit. + +### Branch Naming + +Use short, descriptive branch names that begin with a category prefix and use +hyphens to separate words. Always branch from the current `develop`: + +- `feat/` for new functionality, e.g. `feat/per-key-residency`. +- `fix/` for bug fixes, e.g. `fix/timescale-decimal-serialization`. +- `docs/` for documentation-only changes. +- `refactor/` for internal changes that do not alter behavior. +- `test/` for changes that only add or adjust tests. +- `chore/` for maintenance work such as dependency updates or tooling. + +When a branch addresses a tracked issue, include the issue number, e.g. +`fix/142-oom-attribution`. + +```bash +git switch develop +git pull origin develop +git switch -c feat/per-key-residency +# work, commit, push, open a pull request into develop +``` + +### Hotfixes + +An urgent fix to the released version is the only exception to the rule that work +starts from `develop`. A hotfix branch is created from `main`, named with the +`fix/` prefix, merged into `main` as a patch release, and then merged back into +`develop` so the fix is not lost. + +### Keeping Branches Current + +Keep your branch up to date with `develop`. We prefer rebasing over merging to +keep the history linear: + +```bash +git fetch origin +git rebase origin/develop +``` + +Resolve conflicts locally, run the full set of checks again, and continue. Avoid +merge commits inside feature branches. + +## Commit Messages + +We follow the [Conventional Commits](https://www.conventionalcommits.org/) +specification. + +``` +(): + + + + +``` + +The summary line is in the imperative mood, does not end with a period, and stays +within roughly fifty characters. The body, when present, explains what changed +and why rather than how, wrapped at seventy-two characters. + +Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, +`build`, `ci`, `chore`. A breaking change is indicated with a `!` after the type +(`feat!:`) or a `BREAKING CHANGE:` footer describing the impact and migration. + +``` +feat(deepmem): fold memray stacks to the first user source line + +Allocations are now attributed to the caller's line rather than into +numpy/dask internals, so the flamegraph reads on your own code. +``` + +## Pull Requests + +- Make sure your branch is rebased on the latest `develop` and that all checks + pass locally. +- Push your branch and open a pull request against `develop`. The only pull + requests that target `main` are release merges and hotfixes. +- Fill in the description: explain the motivation, summarize the changes, and + link related issues with a closing keyword such as `Closes #142`. +- Keep pull requests focused on a single concern. +- Be responsive to review feedback; push follow-up commits during review, and we + squash them on merge so the final history stays clean. + +### Review and Merge + +- At least one maintainer approval is required. +- Continuous integration must be green. +- We merge with the squash strategy so each pull request becomes a single commit + on `develop`. The squash commit message must follow Conventional Commits. + +## Testing + +We use pytest. New features must be accompanied by tests, and bug fixes should +include a regression test that fails before the fix and passes after it. + +```bash +uv run pytest -m "not integration" # fast, deterministic suite +uv run pytest -m integration # spins up a real LocalCluster + collector +``` + +Tests marked `integration` start real clusters and are excluded from the default +run so the standard suite stays fast. Aim for meaningful coverage of the behavior +you add, including error paths (worker death, schema mismatch, memray-unavailable +degradation). + +## Versioning and Releases + +This project follows [Semantic Versioning](https://semver.org/). While below +`1.0.0` the public interface should be considered unstable, and minor versions +may include breaking changes. + +### Cutting a Release + +Releases are cut by a maintainer by promoting `develop` to `main` and tagging the +result: + +1. Ensure `develop` is green and contains all changes intended for the release. +2. On `develop`, update the version in `pyproject.toml` and in + `src/daskgenie/__init__.py`, and move the entries under the **Unreleased** + heading of `CHANGELOG.md` into a new section for the version. Commit this. +3. Open a pull request from `develop` into `main` and merge it (with a regular + merge commit, not squashed) once approved and green. +4. Check out `main`, pull the merge, and create an annotated tag prefixed with + `v`: + + ```bash + git switch main + git pull origin main + git tag -a v0.2.0 -m "Release 0.2.0" + git push origin v0.2.0 + ``` + +Pushing the tag triggers the publish workflow (`.github/workflows/workflow-pypi.yml`), +which builds the distributions, publishes them to PyPI using a Trusted Publisher +(so no API token is stored in the repository), builds and pushes the collector and +dashboard images to GHCR, and creates the GitHub release. No manual release +command is needed. + +Tags must always point to a commit on `main` and must never be moved or deleted +once published. After the release, `main` is merged back into `develop` if the +release introduced commits not already present there. + +## Reporting Issues + +If you find a bug or want to request a feature, please open an issue. A good +report includes: + +- A clear and descriptive title. +- The version of DaskGenie and of Python (and OS) you are using. +- The steps required to reproduce the problem. +- What you expected to happen and what actually happened. +- Any relevant logs or tracebacks. + +If you believe you have found a security vulnerability, report it privately to +the maintainers rather than opening a public issue. + +Thank you for helping make DaskGenie better. diff --git a/src/daskgenie/__init__.py b/src/daskgenie/__init__.py index 07f63c9..e243e7e 100644 --- a/src/daskgenie/__init__.py +++ b/src/daskgenie/__init__.py @@ -9,6 +9,8 @@ from daskgenie.local_profiler import LocalProfiler from daskgenie.report import create_run, upload_graph +__version__ = "0.1.0" + __all__ = [ "SourceLocation", "GraphInfo", @@ -19,4 +21,5 @@ "LocalProfiler", "upload_graph", "create_run", + "__version__", ]