diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..8a49e32f1b0945893f10023a2ee4cab500584f30 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +# Version control +.git/ + +# Virtual environments +.venv/ + +# Node modules (reinstalled in build) +node_modules/ + +# Build outputs (rebuilt in Docker) +src/aspara/dashboard/static/dist/ + +# Test artifacts +test-results/ +playwright-report/ +.coverage +htmlcov/ +coverage/ + +# Documentation build +site/ +docs/ + +# IDE / OS +.idea/ +.DS_Store +Thumbs.db +*.swp +*.swo + +# Cache +.mypy_cache/ +.ruff_cache/ +__pycache__/ + +# Environment files +.env +.env.* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..266433236df83cbfa42bdd346a71d19ecec71b5b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +name: CI + +# Security note for public repositories: +# If this repository becomes public, configure the following in +# Settings > Actions > General > "Fork pull request workflows from outside collaborators": +# Set to "Require approval for all outside collaborators" + +on: + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - run: uv sync --dev --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + - uses: pnpm/action-setup@v4 + with: + version: 10.6.3 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm lint + + python-test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.14'] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.6.3 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm build + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + - run: uv sync --dev --locked + - run: uv run pytest --cov=src/aspara --cov-report=xml --cov-report=term-missing + - uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + flags: python + fail_ci_if_error: false + + js-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.6.3 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm test:ci + - uses: codecov/codecov-action@v4 + with: + files: ./coverage/coverage-final.json + flags: javascript + fail_ci_if_error: false + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.6.3 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm build diff --git a/.github/workflows/hf-deploy.yml b/.github/workflows/hf-deploy.yml new file mode 100644 index 0000000000000000000000000000000000000000..8ceb1ea3a998a444acc945b50c1c87a9c79774ee --- /dev/null +++ b/.github/workflows/hf-deploy.yml @@ -0,0 +1,47 @@ +name: Deploy to HF Spaces + +on: + push: + branches: [main] + paths-ignore: + - "docs/**" + - "mkdocs.yml" + - "tests/**" + - "*.md" + workflow_dispatch: + +concurrency: + group: hf-deploy + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Fetch HF Space config + run: | + git fetch origin hf-space + git checkout origin/hf-space -- Dockerfile .dockerignore space_README.md + + - name: Prepare HF Space + run: | + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + # Create orphan branch (no history) to avoid binary files in past commits + git checkout --orphan hf-deploy + git rm -rf --cached docs/ tests/ >/dev/null 2>&1 || true + rm -rf docs/ tests/ + cp space_README.md README.md + git add -A + git commit -m "Deploy to HF Spaces" + + - name: Push to Hugging Face + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + git push --force \ + https://hf:${HF_TOKEN}@huggingface.co/spaces/PredNext/aspara \ + HEAD:main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..caddd2b6200cccec8efdb91b98700c29f4ca2832 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +*.egg +dist/ +build/ +*.whl + +# Virtual environment +.venv/ + +# Node.js +node_modules/ + +# Build output +src/aspara/dashboard/static/dist/ + +# Test +test-results/ +playwright-report/ +.coverage +htmlcov/ +coverage/ + +# Logs +logs/ +*.log + +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +*.swp +*.swo + +# Linter / Type checker cache +.mypy_cache/ +.ruff_cache/ + +# Environment variables +.env +.env.* + +# Database +*.sqlite +*.db + +# uv +.python-version + +# MkDocs +site/ diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000000000000000000000000000000000000..41d983cad8b7a912cc1f9e76d5b55ac8d77fe4d3 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,16 @@ +version: 2 + +build: + os: "ubuntu-24.04" + tools: + python: "3.12" + +mkdocs: + configuration: mkdocs.yml + +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000000000000000000000000000000000000..bd62d7d21d30f446c82099c320cde554629ec421 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,114 @@ +# Development Guide + +This document is a developer guide for Aspara. + +## Setup + +### Python dependencies + +```bash +uv sync --dev +``` + +### JavaScript dependencies + +```bash +pnpm install +``` + +## Building Assets + +After cloning the repository, you must build frontend assets before running Aspara. +These build artifacts are not tracked in git, but are included in pip packages. + +### Build all assets (CSS + JavaScript) + +```bash +pnpm build +``` + +This command generates: +- CSS: `src/aspara/dashboard/static/dist/css/styles.css` +- JavaScript: `src/aspara/dashboard/static/dist/*.js` + +### Build CSS only + +```bash +pnpm run build:css +``` + +### Build JavaScript only + +```bash +pnpm run build:js +``` + +### Development mode (watch mode) + +To automatically detect file changes and rebuild during development: + +```bash +# Watch CSS +pnpm run watch:css + +# Watch JavaScript +pnpm run watch:js +``` + +## Testing + +### Python tests + +```bash +uv run pytest +``` + +### JavaScript tests + +```bash +pnpm test +``` + +### E2E tests + +```bash +npx playwright test +``` + +## Linting and Formatting + +### Python + +```bash +# Lint +ruff check . + +# Format +ruff format . +``` + +### JavaScript + +```bash +# Lint +pnpm lint + +# Format +pnpm format +``` + +## Documentation + +### Build documentation + +```bash +uv run mkdocs build +``` + +### Serve documentation locally + +```bash +uv run mkdocs serve +``` + +You can view the documentation by accessing http://localhost:8000 in your browser. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..15494930ed0705f126d0d1e5b0a94b72b0f90bb7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# ============================================================================== +# Aspara Demo - Hugging Face Spaces +# Multi-stage build: frontend (Node.js) + backend (Python/FastAPI) +# ============================================================================== + +# ------------------------------------------------------------------------------ +# Stage 1: Frontend build (JS + CSS + icons) +# ------------------------------------------------------------------------------ +FROM node:22-slim AS frontend-builder + +WORKDIR /app + +# Enable pnpm via corepack +RUN corepack enable && corepack prepare pnpm@10.6.3 --activate + +# Install JS dependencies (cache layer) +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +# Copy source and build frontend assets +COPY vite.config.js icons.config.json ./ +COPY scripts/ ./scripts/ +COPY src/aspara/dashboard/ ./src/aspara/dashboard/ +RUN pnpm run build:icons && pnpm run build:js && pnpm run build:css + +# ------------------------------------------------------------------------------ +# Stage 2: Python runtime + sample data generation +# ------------------------------------------------------------------------------ +FROM python:3.12-slim + +WORKDIR /app + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Copy Python project files +COPY pyproject.toml uv.lock ./ +COPY space_README.md ./README.md +COPY src/ ./src/ + +# Install Python dependencies (dashboard extra only, no dev deps) +RUN uv sync --frozen --extra dashboard --no-dev + +# Overwrite with built frontend assets +COPY --from=frontend-builder /app/src/aspara/dashboard/static/dist/ ./src/aspara/dashboard/static/dist/ + +# Generate sample data during build +COPY examples/generate_random_runs.py ./examples/ +ENV ASPARA_DATA_DIR=/data/aspara +ENV ASPARA_ALLOW_IFRAME=1 +ENV ASPARA_READ_ONLY=1 +RUN mkdir -p /data/aspara && uv run python examples/generate_random_runs.py + +# Create non-root user (HF Spaces best practice) +RUN useradd -m -u 1000 user && \ + chown -R user:user /data /app +USER user + +# HF Spaces uses port 7860 +EXPOSE 7860 + +# Start dashboard only (no tracker = no external write API) +CMD ["uv", "run", "aspara", "serve", "--host", "0.0.0.0", "--port", "7860", "--data-dir", "/data/aspara"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..5ac86e3dd68ef7184c7b09f86c702aa4a8b0d894 --- /dev/null +++ b/LICENSE @@ -0,0 +1,178 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no theory of + liability, whether in contract, strict liability, or tort + (including negligence or otherwise) arising in any way out of + the use or inability to use the Work (even if such Holder has + been advised of the possibility of such damages), shall any + Contributor be liable to You for damages, including any direct, + indirect, special, incidental, or consequential damages of any + character arising as a result of this License or out of the use + or inability to use the Work (including but not limited to + damages for loss of goodwill, work stoppage, computer failure or + malfunction, or any and all other commercial damages or losses), + even if such Contributor has been advised of the possibility of + such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..58f820354c4509d9db1a61e0c44f3863a02df176 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +--- +title: Aspara Demo +emoji: 🌱 +colorFrom: green +colorTo: blue +sdk: docker +app_port: 7860 +pinned: false +--- + +# Aspara Demo + +Aspara — a blazingly fast metrics tracker for machine learning experiments. + +This Space runs a demo dashboard with pre-generated sample data. +Browse projects, compare runs, and explore metrics to see what Aspara can do. + +## Features + +- LTTB-based metric downsampling for responsive charts +- Run comparison with overlay charts +- Tag and note editing +- Real-time updates via SSE + +## Links + +- [GitHub Repository](https://github.com/prednext/aspara) diff --git a/benchmarks/bench_metrics_api.py b/benchmarks/bench_metrics_api.py new file mode 100644 index 0000000000000000000000000000000000000000..bb7dbb691bde6ad9da615e17eae74b6dcc87add3 --- /dev/null +++ b/benchmarks/bench_metrics_api.py @@ -0,0 +1,200 @@ +"""Benchmark for the /api/projects/{project}/runs/metrics endpoint. + +Measures response time and size for both JSON and msgpack formats. +Uses httpx AsyncClient with ASGITransport so no server process is needed. + +Usage: + uv run python benchmarks/bench_metrics_api.py + uv run python benchmarks/bench_metrics_api.py --runs 10 --metrics 20 --steps 2000 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import statistics +import tempfile +import time +from pathlib import Path + +import httpx +import msgpack + +from aspara.dashboard.dependencies import configure_data_dir +from aspara.dashboard.main import app + + +def create_test_data(data_dir: Path, n_runs: int, n_metrics: int, n_steps: int) -> str: + """Create test JSONL data files. + + Returns: + Project name used for the test data. + """ + project = "bench_project" + project_dir = data_dir / project + project_dir.mkdir(parents=True, exist_ok=True) + + base_ts = 1700000000000 # Fixed base timestamp in ms + + for r in range(n_runs): + run_name = f"run_{r}" + run_file = project_dir / f"{run_name}.jsonl" + meta_file = project_dir / f"{run_name}.meta.json" + + # Write metrics JSONL + with run_file.open("w") as f: + for s in range(n_steps): + entry = { + "timestamp": base_ts + s * 1000, + "step": s, + "metrics": {f"metric_{m}": s * 0.1 + m for m in range(n_metrics)}, + } + f.write(json.dumps(entry) + "\n") + + # Write minimal metadata + meta = { + "run_id": run_name, + "tags": [], + "notes": "", + "params": {}, + "config": {}, + "artifacts": [], + "summary": {}, + "is_finished": True, + "exit_code": 0, + "start_time": base_ts, + "finish_time": base_ts + n_steps * 1000, + } + meta_file.write_text(json.dumps(meta)) + + return project + + +async def bench_endpoint( + client: httpx.AsyncClient, + url: str, + n_warmup: int = 3, + n_iterations: int = 20, +) -> tuple[list[float], int]: + """Benchmark a single endpoint. + + Returns: + (list of response times in ms, response size in bytes) + """ + # Warmup + for _ in range(n_warmup): + resp = await client.get(url, headers={"X-Requested-With": "benchmark"}) + assert resp.status_code == 200, f"Status {resp.status_code}: {resp.text[:200]}" + + # Measure + times: list[float] = [] + size = 0 + for _ in range(n_iterations): + start = time.perf_counter() + resp = await client.get(url, headers={"X-Requested-With": "benchmark"}) + elapsed = (time.perf_counter() - start) * 1000 # ms + times.append(elapsed) + size = len(resp.content) + + return times, size + + +def bench_serialization_only(data: dict, n_iterations: int = 100) -> dict[str, list[float]]: + """Benchmark raw serialization of the metrics dict. + + Returns: + Dict mapping format name to list of times in ms. + """ + results: dict[str, list[float]] = {} + + # JSON via stdlib + json_times: list[float] = [] + for _ in range(n_iterations): + start = time.perf_counter() + json.dumps(data) + elapsed = (time.perf_counter() - start) * 1000 + json_times.append(elapsed) + results["json (stdlib)"] = json_times + + # msgpack + msgpack_times: list[float] = [] + for _ in range(n_iterations): + start = time.perf_counter() + msgpack.packb(data, use_single_float=True) + elapsed = (time.perf_counter() - start) * 1000 + msgpack_times.append(elapsed) + results["msgpack"] = msgpack_times + + return results + + +def print_stats(label: str, times: list[float], size_kb: float | None = None) -> None: + """Print timing statistics.""" + median = statistics.median(times) + p95 = sorted(times)[int(len(times) * 0.95)] + mean = statistics.mean(times) + size_str = f" size={size_kb:.1f}KB" if size_kb is not None else "" + print(f" {label:30s} median={median:7.2f}ms p95={p95:7.2f}ms mean={mean:7.2f}ms{size_str}") + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Benchmark metrics API endpoint") + parser.add_argument("--runs", type=int, default=5, help="Number of runs (default: 5)") + parser.add_argument("--metrics", type=int, default=10, help="Number of metrics per run (default: 10)") + parser.add_argument("--steps", type=int, default=1000, help="Number of steps per metric (default: 1000)") + parser.add_argument("--iterations", type=int, default=20, help="Number of benchmark iterations (default: 20)") + args = parser.parse_args() + + print(f"Benchmark config: {args.runs} runs x {args.metrics} metrics x {args.steps} steps") + print(f"Iterations: {args.iterations}") + print() + + with tempfile.TemporaryDirectory() as tmp: + data_dir = Path(tmp) + project = create_test_data(data_dir, args.runs, args.metrics, args.steps) + configure_data_dir(str(data_dir)) + + try: + run_names = ",".join(f"run_{r}" for r in range(args.runs)) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + url_json = f"/api/projects/{project}/runs/metrics?runs={run_names}&format=json" + url_msgpack = f"/api/projects/{project}/runs/metrics?runs={run_names}&format=msgpack" + + print("--- Endpoint response time ---") + json_times, json_size = await bench_endpoint(client, url_json, n_iterations=args.iterations) + msgpack_times, msgpack_size = await bench_endpoint(client, url_msgpack, n_iterations=args.iterations) + + print_stats("JSON (Pydantic+Rust)", json_times, json_size / 1024) + print_stats("msgpack", msgpack_times, msgpack_size / 1024) + + json_median = statistics.median(json_times) + msgpack_median = statistics.median(msgpack_times) + print(f"\n JSON/msgpack ratio: {json_median / msgpack_median:.2f}x") + + # Serialization-only benchmark + print("\n--- Serialization only (raw dict -> bytes) ---") + # Build a representative data dict + sample_data: dict = {"project": project, "metrics": {}} + for m in range(args.metrics): + metric_dict: dict = {} + for r in range(args.runs): + metric_dict[f"run_{r}"] = { + "steps": list(range(args.steps)), + "values": [i * 0.1 for i in range(args.steps)], + "timestamps": [1700000000000 + i * 1000 for i in range(args.steps)], + } + sample_data["metrics"][f"metric_{m}"] = metric_dict + + ser_results = bench_serialization_only(sample_data, n_iterations=args.iterations) + for label, times in ser_results.items(): + size = len(json.dumps(sample_data).encode()) / 1024 if "json" in label else len(msgpack.packb(sample_data, use_single_float=True)) / 1024 + print_stats(label, times, size) + finally: + configure_data_dir(None) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000000000000000000000000000000000000..d2a20f664ec59c9f0166b8510b4949c175920007 --- /dev/null +++ b/biome.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off" + }, + "style": { + "useImportType": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 160 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always", + "trailingCommas": "es5" + } + }, + "overrides": [ + { + "include": ["tests/fixtures.js"], + "linter": { + "rules": { + "correctness": { + "noEmptyPattern": "off" + } + } + } + } + ], + "files": { + "include": ["src/**/*.js", "tests/**/*.js", "*.js"], + "ignore": ["node_modules/**", "dist/**", "build/**", "docs/**", "site/**", "site.old/**", "playwright-report/**", ".venv", "coverage/**"] + } +} diff --git a/examples/generate_random_runs.py b/examples/generate_random_runs.py new file mode 100644 index 0000000000000000000000000000000000000000..489b54585f9e2d5facd817a4a6bfde6d64c6aaaf --- /dev/null +++ b/examples/generate_random_runs.py @@ -0,0 +1,228 @@ +""" +Sample script to generate multiple random experiment runs. +Creates 4 different runs, each recording 100 steps of metrics. +""" + +import math +import random + +import aspara + + +def generate_metrics_with_trend( + step: int, + total_steps: int, + base_values: dict[str, float], + noise_levels: dict[str, float], + trends: dict[str, float], +) -> dict[str, float]: + """ + Generate metrics with trend and noise. + + Args: + step: Current step + total_steps: Total number of steps + base_values: Initial values for each metric + noise_levels: Noise level for each metric + trends: Final change amount for each metric + + Returns: + Generated metrics + """ + progress = step / total_steps + metrics = {} + + for metric_name, base_value in base_values.items(): + # Change due to trend (linear + slight exponential component) + trend_factor = progress * (1.0 + 0.2 * math.log(1 + 5 * progress)) + trend_change = trends[metric_name] * trend_factor + + # Random noise (sine wave + Gaussian noise) + noise = ( + noise_levels[metric_name] * math.sin(step * 0.2) * 0.3 # Periodic noise + + noise_levels[metric_name] * random.gauss(0, 0.5) # Random noise + ) + + # Calculate final value + value = base_value + trend_change + noise + + # Limit value range (accuracy between 0-1, loss >= 0) + if "accuracy" in metric_name: + value = max(0.0, min(1.0, value)) + elif "loss" in metric_name: + value = max(0.01, value) + + metrics[metric_name] = value + + return metrics + + +def create_run_config(run_id: int) -> tuple[dict[str, float], dict[str, float], dict[str, float]]: + """ + Create configuration for each run. + + Args: + run_id: Run number + + Returns: + Tuple of (initial values, noise levels, trends) + """ + # Set slightly different initial values for each run + base_values = { + "accuracy": 0.3 + random.uniform(-0.1, 0.1), + "loss": 1.0 + random.uniform(-0.2, 0.2), + "val_accuracy": 0.25 + random.uniform(-0.1, 0.1), + "val_loss": 1.1 + random.uniform(-0.2, 0.2), + } + + # Set noise levels + noise_levels = { + "accuracy": 0.02 + 0.01 * run_id, + "loss": 0.05 + 0.02 * run_id, + "val_accuracy": 0.03 + 0.01 * run_id, + "val_loss": 0.07 + 0.02 * run_id, + } + + # Set trends (accuracy increases, loss decreases) + trends = { + "accuracy": 0.5 + random.uniform(-0.1, 0.1), # Upward trend + "loss": -0.8 + random.uniform(-0.1, 0.1), # Downward trend + "val_accuracy": 0.45 + random.uniform(-0.1, 0.1), # Upward trend (slightly lower than train) + "val_loss": -0.75 + random.uniform(-0.1, 0.1), # Downward trend (slightly higher than train) + } + + return base_values, noise_levels, trends + + +def generate_run( + project: str, + run_id: int, + total_steps: int = 100, + project_tags: list[str] | None = None, + run_name: str | None = None, +) -> None: + """ + Generate an experiment run with the specified ID. + + Args: + project: Project name + run_id: Run number + total_steps: Number of steps to generate + project_tags: Common tags for the project + run_name: Run name (generated from run_id if not specified) + """ + # Initialize run + if run_name is None: + run_name = f"random_training_run_{run_id}" + + print(f"Starting generation of run {run_id} for project '{project}'! ({run_name})") + + # Create run configuration + base_values, noise_levels, trends = create_run_config(run_id) + + # Add run-specific tags (fruits) to project-common tags (animals) + fruits = ["apple", "pear", "orange", "grape", "banana", "mango"] + num_fruit_tags = random.randint(1, len(fruits)) + run_tags = random.sample(fruits, k=num_fruit_tags) + + aspara.init( + project=project, + name=run_name, + config={ + "learning_rate": 0.01 * (1 + 0.2 * run_id), + "batch_size": 32 * (1 + run_id % 2), + "optimizer": ["adam", "sgd", "rmsprop", "adagrad"][run_id % 4], + "model_type": "mlp", + "hidden_layers": [128, 64, 32], + "dropout": 0.2 + 0.05 * run_id, + "epochs": 10, + "run_id": run_id, + }, + tags=run_tags, + project_tags=project_tags, + ) + + # Simulate training loop + print(f"Generating metrics for {total_steps} steps...") + for step in range(total_steps): + # Generate metrics + metrics = generate_metrics_with_trend(step, total_steps, base_values, noise_levels, trends) + + # Log metrics + aspara.log(metrics, step=step) + + # Show progress (every 10 steps) + if step % 10 == 0 or step == total_steps - 1: + print(f" Step {step}/{total_steps - 1}: accuracy={metrics['accuracy']:.3f}, loss={metrics['loss']:.3f}") + + # Finish run + aspara.finish() + + print(f"Completed generation of run {run_id} for project '{project}'!") + + +def main() -> None: + """Main function: Generate multiple runs.""" + steps_per_run = 100 + + # Cool secret project names + project_names = [ + "Project_Phoenix", + "Operation_Midnight", + "Genesis_Initiative", + "Project_Prometheus", + ] + + # Famous SF titles (mix of Western and Japanese works) + sf_titles = [ + "AKIRA", + "Ghost_in_the_Shell", + "Planetes", + "Steins_Gate", + "Paprika", + "Blade_Runner", + "Dune", + "Neuromancer", + "Foundation", + "The_Martian", + "Interstellar", + "Solaris", + "Hyperion", + "Snow_Crash", + "Contact", + "Arrival", + "Gravity", + "Moon", + "Ex_Machina", + "Tenet", + ] + + print(f"Generating {len(project_names)} projects!") + print(f" Each project has 4-5 runs! ({steps_per_run} steps per run)") + animals = ["dog", "cat", "rabbit", "coala", "bear", "goat"] + + # Shuffle SF titles before using + shuffled_sf_titles = sf_titles.copy() + random.shuffle(shuffled_sf_titles) + sf_title_index = 0 + + # Generate multiple projects, create 4-5 runs for each project + for project_name in project_names: + # Project-common tags (animals) + num_project_tags = random.randint(1, len(animals)) + project_tags = random.sample(animals, k=num_project_tags) + + num_runs = random.randint(4, 5) + for run_id in range(num_runs): + # Use SF title as run name + run_name = shuffled_sf_titles[sf_title_index % len(shuffled_sf_titles)] + sf_title_index += 1 + generate_run(project_name, run_id, steps_per_run, project_tags, run_name) + print("") # Insert blank line + + print("All runs have been generated!") + print(" Check them out on the dashboard!") + + +if __name__ == "__main__": + main() diff --git a/examples/slow_metrics_writer.py b/examples/slow_metrics_writer.py new file mode 100644 index 0000000000000000000000000000000000000000..5075c4c467b359e07dbc77d3ad06a35cef7864f6 --- /dev/null +++ b/examples/slow_metrics_writer.py @@ -0,0 +1,85 @@ +""" +Simple slow metrics writer for testing SSE real-time updates. + +This is a simpler version that's easy to customize. + +Usage: + # Terminal 1: Start dashboard + aspara dashboard + + # Terminal 2: Run this script + uv run python examples/slow_metrics_writer.py + + # Terminal 3 (optional): Run again with different run name + uv run python examples/slow_metrics_writer.py --run experiment_2 + + # Open browser and watch metrics update in real-time! +""" + +import argparse +import math +import random +import time +from datetime import datetime + +from aspara import Run + + +def main(): + parser = argparse.ArgumentParser(description="Slow metrics writer for SSE testing") + parser.add_argument("--project", default="sse_test", help="Project name") + parser.add_argument("--run", default="experiment_1", help="Run name") + parser.add_argument("--steps", type=int, default=30, help="Number of steps") + parser.add_argument("--delay", type=float, default=2.0, help="Delay between steps (seconds)") + args = parser.parse_args() + + # Random parameters for this run + run_seed = hash(args.run) % 1000 + random.seed(run_seed) + + loss_base = 1.2 + random.uniform(-0.2, 0.2) + acc_base = 0.3 + random.uniform(-0.1, 0.1) + noise_level = 0.015 + random.uniform(0, 0.01) + + # Create run + run = Run( + project=args.project, + name=args.run, + tags=["sse", "test", "realtime"], + notes="Testing SSE real-time updates", + ) + + print(f"šŸš€ Writing metrics to {args.project}/{args.run}") + print(f" Steps: {args.steps}, Delay: {args.delay}s") + print(f" Base Loss: {loss_base:.3f}, Base Acc: {acc_base:.3f}") + print(" Open http://localhost:3141 to watch in real-time!\n") + + # Write metrics gradually + for step in range(args.steps): + # Add noise (periodic + random) + noise = noise_level * (math.sin(step * 0.4) * 0.5 + random.gauss(0, 0.5)) + + # Simulate training metrics with noise + loss = max(0.01, (loss_base / (step + 1)) + noise) + accuracy = min(0.99, acc_base + (0.6 * (1.0 - 1.0 / (step + 1))) + noise * 0.3) + + run.log( + { + "loss": loss, + "accuracy": accuracy, + "step_time": 0.1 + (step * 0.01) + random.uniform(-0.01, 0.01), + }, + step=step, + ) + + timestamp = datetime.now().strftime("%H:%M:%S") + print(f"[{timestamp}] Step {step:3d}/{args.steps} | loss={loss:.4f} acc={accuracy:.4f}") + + time.sleep(args.delay) + + run.finish(exit_code=0) + print(f"\nāœ… Completed! Total time: {args.steps * args.delay:.1f}s") + + +if __name__ == "__main__": + main() diff --git a/icons.config.json b/icons.config.json new file mode 100644 index 0000000000000000000000000000000000000000..4141826862f4862f24e7703c58cefe6e9e68d5f1 --- /dev/null +++ b/icons.config.json @@ -0,0 +1,82 @@ +{ + "icons": [ + { + "name": "stop", + "style": "solid", + "id": "status-icon-wip", + "comment": "Work in progress status" + }, + { + "name": "x-mark", + "style": "outline", + "id": "status-icon-failed", + "comment": "Failed status" + }, + { + "name": "check", + "style": "outline", + "id": "status-icon-completed", + "comment": "Completed status" + }, + { + "name": "pencil-square", + "style": "outline", + "id": "icon-edit", + "comment": "Edit/pencil icon for note editing" + }, + { + "name": "arrow-uturn-left", + "style": "outline", + "id": "icon-reset-zoom", + "comment": "Reset zoom for chart controls" + }, + { + "name": "arrows-pointing-out", + "style": "outline", + "id": "icon-fullscreen", + "comment": "Full screen/expand for chart controls" + }, + { + "name": "arrow-down-tray", + "style": "outline", + "id": "icon-download", + "comment": "Download for chart controls" + }, + { + "name": "trash", + "style": "outline", + "id": "icon-delete", + "comment": "Delete/trash icon for delete buttons" + }, + { + "name": "chevron-left", + "style": "outline", + "id": "icon-chevron-left", + "comment": "Chevron left for sidebar collapse" + }, + { + "name": "chevron-right", + "style": "outline", + "id": "icon-chevron-right", + "comment": "Chevron right for sidebar expand" + }, + { + "name": "bars-3", + "style": "outline", + "id": "icon-menu", + "comment": "Hamburger menu icon for settings" + }, + { + "name": "exclamation-triangle", + "style": "outline", + "id": "icon-exclamation-triangle", + "comment": "Warning/danger icon for confirm modal and maybe_failed status" + }, + { + "name": "information-circle", + "style": "outline", + "id": "icon-information-circle", + "comment": "Info icon for confirm modal" + } + ] +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000000000000000000000000000000000000..7ebefa0c2ebd1e078109175b8e6e386ee03d06c0 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,89 @@ +site_name: Aspara User Manual +site_description: Aspara, blazingly fast metrics tracker for machine learning experiments +site_author: Aspara Development Group +copyright: Copyright © 2026 Aspara Development Group +use_directory_urls: false + +theme: + name: material + language: en + logo: aspara-icon.png + favicon: aspara-icon.png + palette: + primary: indigo + accent: indigo + features: + - navigation.tabs + - navigation.sections + - navigation.top + - search.highlight + - content.code.copy + +extra_css: + - aspara-theme.css + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: + - src + selection: + docstring_style: google + rendering: + show_source: true + show_if_no_docstring: false + show_root_heading: false + show_root_toc_entry: false + heading_level: 2 + show_signature_annotations: true + separate_signature: true + merge_init_into_class: false + docstring_section_style: "spacy" + show_symbol_type_heading: true + show_symbol_type_toc: true + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.superfences + - pymdownx.inlinehilite + - admonition + - pymdownx.details + - pymdownx.tabbed: + alternate_style: true + - tables + - footnotes + +nav: + - Home: index.md + - Getting Started: + - Overview: getting-started.md + - User Guide: + - Overview: user-guide/basics.md + - Core Concepts: user-guide/concepts.md + - Metadata and Notes: user-guide/metadata.md + - Visualizing Results in Dashboard: user-guide/dashboard-visualization.md + - Terminal UI: user-guide/terminal-ui.md + - Best Practices: user-guide/best-practices.md + - Troubleshooting: user-guide/troubleshooting.md + - Advanced: + - Configuration: advanced/configuration.md + - LocalRun vs RemoteRun: advanced/local-vs-remote.md + - Storage: advanced/storage.md + - Dashboard: advanced/dashboard.md + - Tracker API: advanced/tracker-api.md + - Read-only Mode: advanced/read-only-mode.md + - Examples: + - Overview: examples/index.md + - PyTorch: examples/pytorch_example.md + - TensorFlow / Keras: examples/tensorflow_example.md + - scikit-learn: examples/sklearn_example.md + - API Reference: + - Overview: api/index.md + - aspara: api/aspara.md + - Run: api/run.md + - Dashboard API: api/dashboard.md + - Tracker API: api/tracker.md + - Contributing: contributing.md diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..7c240e36446d77af9f655c67ebf7adaf9208fb16 --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "aspara", + "version": "0.1.0", + "type": "module", + "description": "", + "main": "index.js", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:ui": "vitest --ui", + "test:ci": "vitest run --coverage", + "lint": "biome lint", + "format": "biome format --write", + "check": "biome check --write", + "build:icons": "node scripts/build-icons.js", + "build:js": "vite build", + "build:css": "tailwindcss -i ./src/aspara/dashboard/static/css/input.css -o ./src/aspara/dashboard/static/dist/css/styles.css --minify", + "watch:css": "tailwindcss -i ./src/aspara/dashboard/static/css/input.css -o ./src/aspara/dashboard/static/dist/css/styles.css --watch", + "build": "pnpm run build:icons && pnpm run build:js && pnpm run build:css" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "packageManager": "pnpm@10.6.3", + "dependencies": { + "@jcubic/tagger": "^0.6.2", + "@msgpack/msgpack": "^3.1.3" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@playwright/test": "^1.58.2", + "@swc/core": "^1.15.17", + "@tailwindcss/cli": "^4.2.1", + "@testing-library/dom": "^10.4.1", + "@vitest/coverage-v8": ">=4.1.0", + "@vitest/ui": ">=4.1.0", + "canvas": "npm:@napi-rs/canvas@^0.1.95", + "happy-dom": "^20.8.9", + "heroicons": "^2.2.0", + "jsdom": "^26.1.0", + "tailwindcss": "^4.2.1", + "vite": "^7.3.5", + "vitest": ">=4.1.0" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000000000000000000000000000000000000..3c86185bdb685661ae7f132faf0bffb67f9c972e --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,68 @@ +// @ts-check +import { defineConfig, devices } from '@playwright/test'; + +const BASE_PORT = 6113; + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + // Target only E2E tests (avoid conflicts with Vitest) + testDir: './tests/e2e', + + // Number of parallel workers + // 2 workers in CI, unlimited locally + workers: process.env.CI ? 2 : undefined, + + // Test execution timeout + timeout: 30 * 1000, + + // Test expectations + expect: { + // Maximum wait time for an element to become visible + timeout: 5000, + }, + + // Take screenshots of failed tests + use: { + // Base URL + baseURL: `http://localhost:${BASE_PORT}`, + + // Take screenshots + screenshot: 'only-on-failure', + + // Record traces + trace: 'on-first-retry', + + // Allow downloads + acceptDownloads: true, + }, + + // Test report format + // 'list' only outputs to the console and does not generate an HTML report + reporter: process.env.CI ? 'github' : 'list', + + // Automatically start the server before tests + webServer: { + command: `uv run aspara dashboard --port ${BASE_PORT}`, + port: BASE_PORT, + reuseExistingServer: !process.env.CI, + timeout: 60 * 1000, + }, + + // Project settings + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a01df4ac65e4a8590426365a0aba3cc8eefd08f9 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2528 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@jcubic/tagger': + specifier: ^0.6.2 + version: 0.6.2 + '@msgpack/msgpack': + specifier: ^3.1.3 + version: 3.1.3 + devDependencies: + '@biomejs/biome': + specifier: ^1.9.4 + version: 1.9.4 + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 + '@swc/core': + specifier: ^1.15.17 + version: 1.15.17 + '@tailwindcss/cli': + specifier: ^4.2.1 + version: 4.2.1 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@vitest/coverage-v8': + specifier: '>=4.1.0' + version: 4.1.9(vitest@4.1.9) + '@vitest/ui': + specifier: '>=4.1.0' + version: 4.1.9(vitest@4.1.9) + canvas: + specifier: npm:@napi-rs/canvas@^0.1.95 + version: '@napi-rs/canvas@0.1.95' + happy-dom: + specifier: ^20.8.9 + version: 20.8.9 + heroicons: + specifier: ^2.2.0 + version: 2.2.0 + jsdom: + specifier: ^26.1.0 + version: 26.1.0(@napi-rs/canvas@0.1.95) + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 + vite: + specifier: ^7.3.5 + version: 7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1) + vitest: + specifier: '>=4.1.0' + version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(happy-dom@20.8.9)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1)) + +packages: + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/biome@1.9.4': + resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@1.9.4': + resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@1.9.4': + resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@1.9.4': + resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@1.9.4': + resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@1.9.4': + resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@1.9.4': + resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@1.9.4': + resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@1.9.4': + resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jcubic/tagger@0.6.2': + resolution: {integrity: sha512-Pcs/cx8+GXRUuAyxDLKGE+NutXVOaqixTMZhde40R8gMg+paLzdfO3LmmXZ1IYmkm8Nb3a2RyG2N8ZLxcIR3fg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@msgpack/msgpack@3.1.3': + resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==} + engines: {node: '>= 18'} + + '@napi-rs/canvas-android-arm64@0.1.95': + resolution: {integrity: sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.95': + resolution: {integrity: sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.95': + resolution: {integrity: sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95': + resolution: {integrity: sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.95': + resolution: {integrity: sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.95': + resolution: {integrity: sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.95': + resolution: {integrity: sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.95': + resolution: {integrity: sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.95': + resolution: {integrity: sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.95': + resolution: {integrity: sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.95': + resolution: {integrity: sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.95': + resolution: {integrity: sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==} + engines: {node: '>= 10'} + + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/core-darwin-arm64@1.15.17': + resolution: {integrity: sha512-eB9qdyt4E60323IS0rgV/rd79DJ+YWSyIKi+sT1dlIgR3ns4xlBiunREM3lVH0FKcUbhttiBvdVubT4QoOuZ+w==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.17': + resolution: {integrity: sha512-k1TZARYs8947jJpSioqcPrusz+wEeABF4iiSdwcSyQh2rIUdIEk5FOyaqJASFPJ6dZfx7ZVOyjtDATVAegs2/Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.17': + resolution: {integrity: sha512-p6282NQZo5bzx0wphz1ETGjhcRB9CN+/XUAjQwApyoyX9iCloI5IT/RC3vjbflo42g8RPTxUTaItAO0hlLSesQ==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.17': + resolution: {integrity: sha512-TGnDS4ejy8y9jqxXqZCyA+DvFc64nXUHS9rxdyeJ9B9uyIdtKVhBrA2xfghYRS/sSPSyHZ0yu89NxBICvONH+A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.15.17': + resolution: {integrity: sha512-D0/6Hj4CkgSTTahtlGxv9IDsLTuvQz30mkZEMDp8TqwYhCL8AomznkibwlQU8HtY4q/dqd1OGRPH+FmNb4BBEA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-x64-gnu@1.15.17': + resolution: {integrity: sha512-1s2OFsg6DeRkWU7c+PIfIHZsFCbiZ34akXFHrg7KjpF8zIvpHZNoUUZimoWEwcB6GquXSkAO+1b5KpG5nusTeQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.15.17': + resolution: {integrity: sha512-gtxGMGYtRWWmCcgx6xM2Yos43uiE/j8kZwkeL/LNGG9zM0tatd23NsfL9PnQJ45hY7QZ+dx2rM68e4ArgG4kJg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.15.17': + resolution: {integrity: sha512-gxi+/Miytez/O9vJ/QiheIivA3oWZjPp9nJu3VmAfLMWUzcZORMwgaI1ygtDTLjz7CzcwlGMJz/Ab66Y5DfNpg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.17': + resolution: {integrity: sha512-KUsRqNbTp7SpNK0T9m4+i8GlngzNjwb69a3ttKA6XJ5r6Pewm+NSYji93pNkawXIivbWY2jhvceGMAyd+4hWaQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.17': + resolution: {integrity: sha512-zqtEGE0/rTKvEC5sOtpANLHeWEPjsTD4/rwpUxo6ymztcLI/Z+L9Wi9xQvIGmLTUih1gvNZcAwROqdfRP3oAWQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.17': + resolution: {integrity: sha512-Mu3eOrYlkdQPl7yqotNckitTr6FZ0yd7mlWIBEzK+EGIyybgMENJHmbS2DeA7BMleJiBElP6ke+Nz93pkKmKJw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + + '@tailwindcss/cli@4.2.1': + resolution: {integrity: sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==} + hasBin: true + + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} + peerDependencies: + '@vitest/browser': 4.1.9 + vitest: 4.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/ui@4.1.9': + resolution: {integrity: sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==} + peerDependencies: + vitest: 4.1.9 + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} + engines: {node: '>=10.13.0'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + happy-dom@20.8.9: + resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==} + engines: {node: '>=20.0.0'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + heroicons@2.2.0: + resolution: {integrity: sha512-yOwvztmNiBWqR946t+JdgZmyzEmnRMC2nxvHFC90bF1SUttwB6yJKYeme1JeEcBfobdOs827nCyiWBS2z/brog==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/runtime@7.28.6': {} + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/biome@1.9.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 1.9.4 + '@biomejs/cli-darwin-x64': 1.9.4 + '@biomejs/cli-linux-arm64': 1.9.4 + '@biomejs/cli-linux-arm64-musl': 1.9.4 + '@biomejs/cli-linux-x64': 1.9.4 + '@biomejs/cli-linux-x64-musl': 1.9.4 + '@biomejs/cli-win32-arm64': 1.9.4 + '@biomejs/cli-win32-x64': 1.9.4 + + '@biomejs/cli-darwin-arm64@1.9.4': + optional: true + + '@biomejs/cli-darwin-x64@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64@1.9.4': + optional: true + + '@biomejs/cli-linux-x64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-x64@1.9.4': + optional: true + + '@biomejs/cli-win32-arm64@1.9.4': + optional: true + + '@biomejs/cli-win32-x64@1.9.4': + optional: true + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@jcubic/tagger@0.6.2': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@msgpack/msgpack@3.1.3': {} + + '@napi-rs/canvas-android-arm64@0.1.95': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.95': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.95': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.95': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.95': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.95': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.95': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.95': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.95': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.95': + optional: true + + '@napi-rs/canvas@0.1.95': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.95 + '@napi-rs/canvas-darwin-arm64': 0.1.95 + '@napi-rs/canvas-darwin-x64': 0.1.95 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.95 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.95 + '@napi-rs/canvas-linux-arm64-musl': 0.1.95 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.95 + '@napi-rs/canvas-linux-x64-gnu': 0.1.95 + '@napi-rs/canvas-linux-x64-musl': 0.1.95 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.95 + '@napi-rs/canvas-win32-x64-msvc': 0.1.95 + + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.3 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + + '@polka/url@1.0.0-next.29': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@swc/core-darwin-arm64@1.15.17': + optional: true + + '@swc/core-darwin-x64@1.15.17': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.17': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.17': + optional: true + + '@swc/core-linux-arm64-musl@1.15.17': + optional: true + + '@swc/core-linux-x64-gnu@1.15.17': + optional: true + + '@swc/core-linux-x64-musl@1.15.17': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.17': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.17': + optional: true + + '@swc/core-win32-x64-msvc@1.15.17': + optional: true + + '@swc/core@1.15.17': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.25 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.17 + '@swc/core-darwin-x64': 1.15.17 + '@swc/core-linux-arm-gnueabihf': 1.15.17 + '@swc/core-linux-arm64-gnu': 1.15.17 + '@swc/core-linux-arm64-musl': 1.15.17 + '@swc/core-linux-x64-gnu': 1.15.17 + '@swc/core-linux-x64-musl': 1.15.17 + '@swc/core-win32-arm64-msvc': 1.15.17 + '@swc/core-win32-ia32-msvc': 1.15.17 + '@swc/core-win32-x64-msvc': 1.15.17 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.25': + dependencies: + '@swc/counter': 0.1.3 + + '@tailwindcss/cli@4.2.1': + dependencies: + '@parcel/watcher': 2.5.6 + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + enhanced-resolve: 5.20.0 + mri: 1.2.0 + picocolors: 1.1.1 + tailwindcss: 4.2.1 + + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@26.0.1': + dependencies: + undici-types: 8.3.0 + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.0.1 + + '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.9 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(happy-dom@20.8.9)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1)) + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/ui@4.1.9(vitest@4.1.9)': + dependencies: + '@vitest/utils': 4.1.9 + fflate: 0.8.2 + flatted: 3.4.2 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(happy-dom@20.8.9)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1)) + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + enhanced-resolve@5.20.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@6.0.1: {} + + entities@7.0.1: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fflate@0.8.2: {} + + flatted@3.4.2: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + happy-dom@20.8.9: + dependencies: + '@types/node': 26.0.1 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + has-flag@4.0.0: {} + + heroicons@2.2.0: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-escaper@2.0.2: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-potential-custom-element-name@1.0.1: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jiti@2.6.1: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsdom@26.1.0(@napi-rs/canvas@0.1.95): + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + optionalDependencies: + canvas: '@napi-rs/canvas@0.1.95' + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + lightningcss-android-arm64@1.31.1: + optional: true + + lightningcss-darwin-arm64@1.31.1: + optional: true + + lightningcss-darwin-x64@1.31.1: + optional: true + + lightningcss-freebsd-x64@1.31.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + + lightningcss-linux-arm64-musl@1.31.1: + optional: true + + lightningcss-linux-x64-gnu@1.31.1: + optional: true + + lightningcss-linux-x64-musl@1.31.1: + optional: true + + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + + lightningcss-win32-x64-msvc@1.31.1: + optional: true + + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + + lru-cache@10.4.3: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-addon-api@7.1.1: {} + + nwsapi@2.2.23: {} + + obug@2.1.1: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + react-is@17.0.2: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@7.7.4: {} + + siginfo@2.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tailwindcss@4.2.1: {} + + tapable@2.3.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.1.0: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + totalist@3.0.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + undici-types@8.3.0: {} + + vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 26.0.1 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + + vitest@4.1.9(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(happy-dom@20.8.9)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.0.1 + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) + '@vitest/ui': 4.1.9(vitest@4.1.9) + happy-dom: 20.8.9 + jsdom: 26.1.0(@napi-rs/canvas@0.1.95) + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@3.0.0: {} + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.19.0: {} + + ws@8.21.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..9cc55d616a934620c3453740f7fa8f695663713d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,118 @@ +[build-system] +requires = ["uv_build>=0.9.26"] +build-backend = "uv_build" + +[project] +name = "aspara" +version = "0.1.0" +description = "Blazingly fast metrics tracker for machine learning experiments" +authors = [ + {name = "TOKUNAGA Hiroyuki"} +] +license = "Apache-2.0" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "polars>=1.37.1", + "pydantic>=2.0", +] + +[project.optional-dependencies] +tracker = [ + "uvicorn>=0.27.0", + "fastapi>=0.115.12", + "starlette>=1.3.1", + "python-multipart>=0.0.31", +] + +dashboard = [ + "uvicorn>=0.27.0", + "sse-starlette>=1.8.0", + "starlette>=1.3.1", + "aiofiles>=23.2.0", + "watchfiles>=1.1.1", + "pystache>=0.6.8", + "fastapi>=0.115.12", + "lttb>=0.3.2", + "numpy>=1.24.0", + "msgpack>=1.2.1", +] + +remote = [ + "requests>=2.31.0", + "urllib3>=2.7.0", + "idna>=3.15", +] + +tui = [ + "textual>=0.47.0", + "textual-plotext>=0.2.0", +] + +all = [ + "aspara[tracker]", + "aspara[dashboard]", + "aspara[remote]", + "aspara[tui]", + "aspara[docs]", +] + +docs = [ + "mkdocs>=1.6.0", + "mkdocs-material>=9.6.0", + "mkdocstrings[python]>=0.24.0", + "mkdocs-autorefs>=0.5.0", +] + +[project.scripts] +aspara = "aspara.cli:main" + +[dependency-groups] +dev = [ + "aspara[all]", + "pytest>=9.0.3", + "pytest-asyncio>=1.0.0", + "pytest-cov>=7.0.0", + "ruff>=0.14.10", + "playwright>=1.52.0", + "pyrefly>=0.46.1", + "py-spy>=0.4.1", + "types-requests>=2.31.0", + "ty>=0.0.12", + "httpx>=0.28.1", +] + +[tool.ruff] +line-length = 160 +indent-width = 4 +target-version = "py310" +extend-exclude = [".venv", "build", "dist"] +src = ["src"] + +[tool.ruff.lint] +select = ["E", "F", "W", "B", "I"] +extend-select = [ + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "ERA", # eradicate + "UP", # pyupgrade +] +extend-ignore = ["SIM108"] + +[tool.pyrefly] +project-includes = [ + "src/**/*.py*", + "tests/**/*.py*", +] + + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +preview = true +line-ending = "auto" +docstring-code-format = true + +[tool.ruff.lint.isort] +known-first-party = ["aspara"] +section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] diff --git a/scripts/build-icons.js b/scripts/build-icons.js new file mode 100644 index 0000000000000000000000000000000000000000..57eef4c8ce8d33a3f234e6ce495f8e42fb96099a --- /dev/null +++ b/scripts/build-icons.js @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +/** + * Build script to generate SVG symbol sprites from heroicons. + * + * Reads icons.config.json and generates _icons.mustache partial + * containing SVG symbols that can be referenced via . + * + * Usage: node scripts/build-icons.js + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = join(__dirname, '..'); + +const CONFIG_PATH = join(ROOT_DIR, 'icons.config.json'); +const OUTPUT_PATH = join(ROOT_DIR, 'src/aspara/dashboard/templates/_icons.mustache'); +const HEROICONS_PATH = join(ROOT_DIR, 'node_modules/heroicons/24'); + +/** + * Parse SVG file and extract attributes and inner content. + * @param {string} svgContent - Raw SVG file content + * @returns {{ attrs: Object, innerContent: string }} + */ +function parseSvg(svgContent) { + // Extract attributes from the opening tag + const svgMatch = svgContent.match(/]*)>([\s\S]*)<\/svg>/); + if (!svgMatch) { + throw new Error('Invalid SVG format'); + } + + const attrsString = svgMatch[1]; + const innerContent = svgMatch[2].trim(); + + // Parse attributes + const attrs = {}; + const attrRegex = /(\S+)=["']([^"']*)["']/g; + for (const match of attrsString.matchAll(attrRegex)) { + attrs[match[1]] = match[2]; + } + + return { attrs, innerContent }; +} + +/** + * Convert SVG to symbol element. + * @param {string} svgContent - Raw SVG file content + * @param {string} id - Symbol ID + * @returns {string} Symbol element string + */ +function svgToSymbol(svgContent, id) { + const { attrs, innerContent } = parseSvg(svgContent); + + // Build symbol attributes (keep viewBox, fill, stroke, stroke-width) + const symbolAttrs = [`id="${id}"`]; + + if (attrs.viewBox) { + symbolAttrs.push(`viewBox="${attrs.viewBox}"`); + } + if (attrs.fill) { + symbolAttrs.push(`fill="${attrs.fill}"`); + } + if (attrs.stroke) { + symbolAttrs.push(`stroke="${attrs.stroke}"`); + } + if (attrs['stroke-width']) { + symbolAttrs.push(`stroke-width="${attrs['stroke-width']}"`); + } + + return ` \n ${innerContent}\n `; +} + +/** + * Main build function. + */ +function build() { + console.log('Building icon sprites...'); + + // Read config + const config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')); + console.log(`Found ${config.icons.length} icons in config`); + + const symbols = []; + + for (const icon of config.icons) { + const svgPath = join(HEROICONS_PATH, icon.style, `${icon.name}.svg`); + console.log(` Processing: ${icon.name} (${icon.style}) -> #${icon.id}`); + + try { + const svgContent = readFileSync(svgPath, 'utf-8'); + const symbol = svgToSymbol(svgContent, icon.id); + symbols.push(symbol); + } catch (err) { + console.error(` Error reading ${svgPath}: ${err.message}`); + process.exit(1); + } + } + + // Generate output + const output = `{{! + Auto-generated icon sprites from heroicons. + Do not edit manually - run "pnpm build:icons" to regenerate. + + Source: icons.config.json +}} + +`; + + writeFileSync(OUTPUT_PATH, output); + console.log(`\nGenerated: ${OUTPUT_PATH}`); + console.log('Done!'); +} + +build(); diff --git a/space_README.md b/space_README.md new file mode 100644 index 0000000000000000000000000000000000000000..58f820354c4509d9db1a61e0c44f3863a02df176 --- /dev/null +++ b/space_README.md @@ -0,0 +1,27 @@ +--- +title: Aspara Demo +emoji: 🌱 +colorFrom: green +colorTo: blue +sdk: docker +app_port: 7860 +pinned: false +--- + +# Aspara Demo + +Aspara — a blazingly fast metrics tracker for machine learning experiments. + +This Space runs a demo dashboard with pre-generated sample data. +Browse projects, compare runs, and explore metrics to see what Aspara can do. + +## Features + +- LTTB-based metric downsampling for responsive charts +- Run comparison with overlay charts +- Tag and note editing +- Real-time updates via SSE + +## Links + +- [GitHub Repository](https://github.com/prednext/aspara) diff --git a/src/aspara/__init__.py b/src/aspara/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ed91989f7239dffc46e1ac8e1aa620039f65c376 --- /dev/null +++ b/src/aspara/__init__.py @@ -0,0 +1,31 @@ +""" +Aspara - Simple metrics tracking system for machine learning experiments. + +This module provides a wandb-compatible API for experiment tracking. + +Examples: + >>> import aspara + >>> run = aspara.init(project="my_project", config={"lr": 0.01}) + >>> aspara.log({"loss": 0.5, "accuracy": 0.95}) + >>> aspara.finish() +""" + +from aspara.run import Config, Run, Summary, finish, init, log +from aspara.run import get_current_run as _get_current_run + +__version__ = "0.1.0" +__all__ = [ + "Run", + "Config", + "Summary", + "init", + "log", + "finish", +] + + +# Convenience function for accessing current run's config +def config() -> Config | None: + """Get the config of the current run.""" + run = _get_current_run() + return run.config if run else None diff --git a/src/aspara/catalog/__init__.py b/src/aspara/catalog/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1a26e031c916d4dc697664136391f1bbc6b0af0e --- /dev/null +++ b/src/aspara/catalog/__init__.py @@ -0,0 +1,18 @@ +""" +Aspara Catalog module + +Provides ProjectCatalog and RunCatalog for discovering and managing +projects and runs in the data directory. +""" + +from .project_catalog import ProjectCatalog, ProjectInfo +from .run_catalog import RunCatalog, RunInfo +from .watcher import DataDirWatcher + +__all__ = [ + "ProjectCatalog", + "RunCatalog", + "ProjectInfo", + "RunInfo", + "DataDirWatcher", +] diff --git a/src/aspara/catalog/project_catalog.py b/src/aspara/catalog/project_catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..d89cbc18942fe3ad07b74d89d84c41063e724fe4 --- /dev/null +++ b/src/aspara/catalog/project_catalog.py @@ -0,0 +1,242 @@ +""" +ProjectCatalog - Catalog for discovering and managing projects. + +This module provides functionality for listing, getting, and deleting projects +in the data directory. +""" + +import logging +import os +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from pydantic import BaseModel + +from aspara.exceptions import ProjectNotFoundError +from aspara.storage import ProjectMetadataStorage +from aspara.utils.validators import validate_name, validate_safe_path + +logger = logging.getLogger(__name__) + + +class ProjectInfo(BaseModel): + """Project information.""" + + name: str + run_count: int + last_update: datetime + + +# Run data file suffixes used when discovering and counting runs. +_RUN_DATA_SUFFIXES = (".jsonl", ".db", ".wal") +_RUN_EXCLUDED_SUFFIXES = (".wal.jsonl", ".meta.jsonl") + + +class ProjectCatalog: + """Catalog for discovering and managing projects. + + This class provides methods to list, get, and delete projects + in the data directory. It does not handle metrics data directly; + that responsibility belongs to MetricsStorage. + """ + + def __init__(self, data_dir: str | Path) -> None: + """Initialize the project catalog. + + Args: + data_dir: Base directory for data storage + """ + self.data_dir = Path(data_dir) + + def _scan_projects(self, *, include_metadata: bool = False) -> list[tuple[ProjectInfo, dict[str, Any] | None]]: + """Scan the data directory and build project information. + + Args: + include_metadata: Whether to load project metadata.json during the scan. + + Returns: + List of (ProjectInfo, metadata) tuples. ``metadata`` is ``None`` when + ``include_metadata`` is ``False``. + """ + results: list[tuple[ProjectInfo, dict[str, Any] | None]] = [] + if not self.data_dir.exists(): + return results + + try: + # Use scandir for efficient iteration with cached stat info + with os.scandir(self.data_dir) as project_entries: + for project_entry in project_entries: + if not project_entry.is_dir(): + continue + + # Skip hidden/reserved directories (e.g. .queue) + if project_entry.name.startswith("."): + continue + + # Collect run files and optionally metadata in a single pass + run_files_mtime: list[float] = [] + metadata: dict[str, Any] | None = None + with os.scandir(project_entry.path) as file_entries: + for file_entry in file_entries: + if include_metadata and file_entry.name == "metadata.json": + metadata = ProjectMetadataStorage.load_metadata_file(Path(file_entry.path)) + elif file_entry.name.endswith(_RUN_DATA_SUFFIXES) and not file_entry.name.endswith(_RUN_EXCLUDED_SUFFIXES): + # stat() result is cached by scandir + run_files_mtime.append(file_entry.stat().st_mtime) + + if include_metadata and metadata is None: + metadata = ProjectMetadataStorage.default_metadata() + + run_count = len(run_files_mtime) + + # Find last update time - use cached stat from scandir + if run_files_mtime: + last_update = datetime.fromtimestamp(max(run_files_mtime), tz=timezone.utc) + else: + last_update = datetime.fromtimestamp(project_entry.stat().st_mtime, tz=timezone.utc) + + results.append(( + ProjectInfo( + name=project_entry.name, + run_count=run_count, + last_update=last_update, + ), + metadata, + )) + except (OSError, PermissionError): + pass + + return sorted(results, key=lambda item: item[0].name) + + def get_projects(self) -> list[ProjectInfo]: + """List all projects in the data directory. + + Uses os.scandir() for efficient directory iteration with cached stat info. + + Returns: + List of ProjectInfo objects sorted by name + """ + return [project for project, _ in self._scan_projects(include_metadata=False)] + + def get_projects_with_metadata(self) -> list[tuple[ProjectInfo, dict[str, Any]]]: + """List all projects with their metadata in a single directory pass. + + Returns: + List of (ProjectInfo, metadata) tuples sorted by project name. + """ + return [(project, metadata) for project, metadata in self._scan_projects(include_metadata=True) if metadata is not None] + + def get(self, name: str) -> ProjectInfo: + """Get a specific project by name. + + Args: + name: Project name + + Returns: + ProjectInfo object + + Raises: + ValueError: If project name is invalid + ProjectNotFoundError: If project does not exist + """ + validate_name(name, "project name") + + project_dir = self.data_dir / name + validate_safe_path(project_dir, self.data_dir) + + if not project_dir.exists() or not project_dir.is_dir(): + raise ProjectNotFoundError(f"Project '{name}' not found") + + # Count runs and collect mtimes in a single scandir pass, using the same + # suffix logic as get_projects() for consistency and reduced I/O. + run_files_mtime: list[float] = [] + try: + with os.scandir(project_dir) as file_entries: + for file_entry in file_entries: + if file_entry.name.endswith(_RUN_DATA_SUFFIXES) and not file_entry.name.endswith(_RUN_EXCLUDED_SUFFIXES): + run_files_mtime.append(file_entry.stat().st_mtime) + except (OSError, PermissionError): + pass + + run_count = len(run_files_mtime) + + # Get last update time from run files + last_update = datetime.fromtimestamp(project_dir.stat().st_mtime, tz=timezone.utc) + if run_files_mtime: + last_update = datetime.fromtimestamp(max(run_files_mtime), tz=timezone.utc) + + return ProjectInfo( + name=name, + run_count=run_count, + last_update=last_update, + ) + + def exists(self, name: str) -> bool: + """Check if a project exists. + + Args: + name: Project name + + Returns: + True if project exists, False otherwise + """ + try: + validate_name(name, "project name") + project_dir = self.data_dir / name + validate_safe_path(project_dir, self.data_dir) + return project_dir.exists() and project_dir.is_dir() + except ValueError: + return False + + def delete(self, name: str) -> None: + """Delete a project and all its runs. + + Args: + name: Project name to delete + + Raises: + ValueError: If project name is empty or invalid + ProjectNotFoundError: If project does not exist + PermissionError: If deletion is not permitted + """ + if not name: + raise ValueError("Project name cannot be empty") + + validate_name(name, "project name") + + project_dir = self.data_dir / name + validate_safe_path(project_dir, self.data_dir) + + if not project_dir.exists(): + raise ProjectNotFoundError(f"Project '{name}' does not exist") + + try: + shutil.rmtree(project_dir) + logger.info(f"Successfully deleted project: {name}") + except (PermissionError, OSError) as e: + logger.error(f"Error deleting project {name}: {type(e).__name__}") + raise + + def get_metadata(self, name: str) -> dict[str, Any]: + """Get project-level metadata.json for a project. + + Returns a dictionary with notes, tags, created_at, updated_at fields. + """ + storage = ProjectMetadataStorage(self.data_dir, name) + return storage.get_metadata() + + def update_metadata(self, name: str, metadata: dict[str, Any]) -> dict[str, Any]: + """Update project-level metadata.json for a project. + + The metadata dict may contain partial fields (notes, tags). + Validation and timestamp handling is delegated to ProjectMetadataStorage. + """ + storage = ProjectMetadataStorage(self.data_dir, name) + return storage.update_metadata(metadata) + + def delete_metadata(self, name: str) -> bool: + """Delete project-level metadata.json for a project.""" + storage = ProjectMetadataStorage(self.data_dir, name) + return storage.delete_metadata() diff --git a/src/aspara/catalog/run_catalog.py b/src/aspara/catalog/run_catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..c2fe5047f56b34f99ad00bfcc12e62e32ed5e8ba --- /dev/null +++ b/src/aspara/catalog/run_catalog.py @@ -0,0 +1,764 @@ +""" +RunCatalog - Catalog for discovering and managing runs within a project. + +This module provides functionality for listing, getting, and deleting runs +in a project directory. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import shutil +from collections.abc import AsyncGenerator, Mapping +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import polars as pl +from pydantic import BaseModel, Field + +from aspara.exceptions import ProjectNotFoundError, RunNotFoundError +from aspara.models import MetricRecord, RunStatus, StatusRecord +from aspara.storage import RunMetadataStorage +from aspara.utils.timestamp import parse_to_datetime +from aspara.utils.validators import validate_name, validate_safe_path + +logger = logging.getLogger(__name__) + +# Threshold in seconds to consider a run as potentially failed (1 hour) +STALE_RUN_THRESHOLD_SECONDS = 3600 + + +class RunInfo(BaseModel): + """Run information.""" + + name: str + run_id: str | None = None + start_time: datetime | None = None + last_update: datetime | None = None + param_count: int + artifact_count: int = 0 + tags: list[str] = [] + is_corrupted: bool = False + error_message: str | None = None + is_finished: bool = False + exit_code: int | None = None + status: RunStatus = Field(default=RunStatus.WIP) + + +def _detect_backend(data_dir: Path, project: str, run_name: str) -> str: + """Detect which storage backend a run is using. + + Args: + data_dir: Base data directory + project: Project name + run_name: Run name + + Returns: + "polars" if the run uses Polars backend (WAL + Parquet), "jsonl" otherwise + """ + project_dir = data_dir / project + + # Check for Polars backend indicators + wal_file = project_dir / f"{run_name}.wal.jsonl" + archive_dir = project_dir / f"{run_name}_archive" + + # If WAL file or archive directory exists, it's a Polars backend + if wal_file.exists() or archive_dir.exists(): + return "polars" + + # Otherwise, it's JSONL backend + return "jsonl" + + +def _open_metrics_storage( + base_dir: Path | str, + project: str, + run_name: str, +): + """Open metrics storage for an existing run. + + Detects the backend type from existing files and returns + the appropriate storage instance. + + Args: + base_dir: Base data directory + project: Project name + run_name: Run name + + Returns: + JsonlMetricsStorage or PolarsMetricsStorage instance + """ + from aspara.storage import JsonlMetricsStorage, PolarsMetricsStorage + + backend = _detect_backend(Path(base_dir), project, run_name) + + if backend == "polars": + return PolarsMetricsStorage( + base_dir=str(base_dir), + project_name=project, + run_name=run_name, + ) + else: + return JsonlMetricsStorage( + base_dir=str(base_dir), + project_name=project, + run_name=run_name, + ) + + +def _read_metadata_file(metadata_file: Path) -> dict: + """Read .meta.json file and return parsed data. + + Args: + metadata_file: Path to the .meta.json file + + Returns: + Dictionary with metadata, or empty dict if file doesn't exist or is invalid + """ + if not metadata_file.exists(): + return {} + + try: + with open(metadata_file) as f: + return json.load(f) + except Exception as e: + logger.warning(f"Error reading metadata file {metadata_file}: {e}") + return {} + + +def _infer_stale_status( + status: RunStatus, + start_time: datetime | None, + is_finished: bool, +) -> RunStatus: + """Infer MAYBE_FAILED status for old runs that were never finished. + + Args: + status: Current run status + start_time: When the run started + is_finished: Whether the run has finished + + Returns: + MAYBE_FAILED if run is stale, otherwise the original status + """ + if status != RunStatus.WIP or not start_time or is_finished: + return status + + current_time = datetime.now(timezone.utc) + age_seconds = (current_time - start_time).total_seconds() + if age_seconds > STALE_RUN_THRESHOLD_SECONDS: + return RunStatus.MAYBE_FAILED + + return status + + +def _extract_timestamp_range( + df: pl.DataFrame, +) -> tuple[datetime | None, datetime | None]: + """Extract start_time and last_update from DataFrame. + + Args: + df: DataFrame with timestamp column + + Returns: + Tuple of (start_time, last_update) + """ + if len(df) == 0 or "timestamp" not in df.columns: + return (None, None) + + timestamps = df.select("timestamp").to_series() + if len(timestamps) == 0: + return (None, None) + + ts_min = timestamps.min() + ts_max = timestamps.max() + + start_time = ts_min if isinstance(ts_min, datetime) else None + last_update = ts_max if isinstance(ts_max, datetime) else None + + return (start_time, last_update) + + +def _check_corruption( + df: pl.DataFrame, + metadata_file_exists: bool, +) -> tuple[bool, str | None]: + """Check if metrics data is corrupted. + + Args: + df: DataFrame with metrics + metadata_file_exists: Whether metadata file exists + + Returns: + Tuple of (is_corrupted, error_message) + """ + if len(df) == 0 and not metadata_file_exists: + return (True, "Empty file! No data found!") + if len(df) > 0 and "timestamp" not in df.columns: + return (True, "No timestamps found! Corrupted Run!") + return (False, None) + + +def _map_error_to_corruption( + error: Exception, + metadata_file_exists: bool, +) -> tuple[bool, str | None]: + """Map storage read errors to corruption status. + + Args: + error: The exception that occurred + metadata_file_exists: Whether metadata file exists + + Returns: + Tuple of (is_corrupted, error_message) + """ + error_str = str(error).lower() + + if "empty" in error_str or "empty string" in error_str: + return (True, "Empty file! No data found!") + if "expectedobjectkey" in error_str.replace(" ", "") or "invalid json" in error_str: + return (True, f"Invalid file format! Error: {error!s}") + if "timestamp" in error_str: + return (True, f"No timestamps found! Error: {error!s}") + if "step" in error_str and not metadata_file_exists: + return (True, f"Failed to read metrics: {error!s}") + if not metadata_file_exists: + return (True, f"Failed to read metrics: {error!s}") + + return (False, None) + + +class RunCatalog: + """Catalog for discovering and managing runs within a project. + + This class provides methods to list, get, delete, and watch runs. + It handles both JSONL and DuckDB storage formats. + """ + + def __init__(self, data_dir: str | Path) -> None: + """Initialize the run catalog. + + Args: + data_dir: Base directory for data storage + """ + self.data_dir = Path(data_dir) + + def _parse_file_path(self, file_path: Path) -> tuple[str, str, str] | None: + """Parse file path to extract project, run name, and file type. + + Args: + file_path: Absolute path to a file (e.g., data/project/run.jsonl) + + Returns: + (project, run_name, file_type) where file_type is 'metrics', 'wal', or 'meta' + None if path doesn't match expected pattern or names are invalid + """ + try: + relative = file_path.relative_to(self.data_dir) + except ValueError: + return None + + parts = relative.parts + if len(parts) != 2: + return None + + project = parts[0] + filename = parts[1] + + if filename.endswith(".wal.jsonl"): + run_name = filename[:-10] + file_type = "wal" + elif filename.endswith(".meta.json"): + run_name = filename[:-10] + file_type = "meta" + elif filename.endswith(".jsonl"): + run_name = filename[:-6] + file_type = "metrics" + else: + return None + + # Validate project and run names so that reserved/hidden directories + # (e.g. .queue) or names with path-traversal characters are rejected + # at parse time rather than leaking into downstream consumers. + try: + validate_name(project, "project name") + validate_name(run_name, "run name") + except ValueError: + return None + + return (project, run_name, file_type) + + def _read_run_info(self, project: str, run_name: str, run_file: Path) -> RunInfo: + """Read run information from JSONL metrics file and metadata file. + + Supports both JSONL and Polars backends. + Optimization: Avoids loading full DataFrame when metadata provides sufficient info. + + Args: + project: Project name + run_name: Run name + run_file: Path to the JSONL metrics file + + Returns: + RunInfo object with metadata from both files + """ + metadata_file = run_file.parent / f"{run_name}.meta.json" + + # Read metadata + metadata = _read_metadata_file(metadata_file) + run_id = metadata.get("run_id") + tags = metadata.get("tags", []) + is_finished = metadata.get("is_finished", False) + exit_code = metadata.get("exit_code") + + # Read params count + params = metadata.get("params", {}) + params_count = len(params) if isinstance(params, dict) else 0 + + # Parse status + status_value = metadata.get("status", RunStatus.WIP.value) + try: + status = RunStatus(status_value) + except ValueError: + status = RunStatus.from_is_finished_and_exit_code(is_finished, exit_code) + + # Parse start_time from metadata + start_time = None + start_time_value = metadata.get("start_time") + if start_time_value is not None: + with contextlib.suppress(ValueError): + start_time = parse_to_datetime(start_time_value) + + # Infer stale status + status = _infer_stale_status(status, start_time, is_finished) + + # Lightweight corruption check: file exists and is not empty. + # Use try/except instead of exists()→stat() to avoid TOCTOU race + # when the file is deleted between the two calls (e.g. watcher and + # delete API running concurrently). + is_corrupted = False + error_message = None + last_update = None + + run_exists = False + run_size = 0 + try: + run_stat = run_file.stat() + run_exists = True + run_size = run_stat.st_size + last_update = datetime.fromtimestamp(run_stat.st_mtime, tz=timezone.utc) + except FileNotFoundError: + pass + + metadata_exists = metadata_file.exists() + + if not run_exists and not metadata_exists: + is_corrupted = True + error_message = "Run file not found" + elif run_exists and run_size == 0 and not metadata_exists: + is_corrupted = True + error_message = "Empty file! No data found!" + + return RunInfo( + name=run_name, + run_id=run_id, + start_time=start_time, + last_update=last_update, + param_count=params_count, + artifact_count=0, + tags=tags, + is_corrupted=is_corrupted, + error_message=error_message, + is_finished=is_finished, + exit_code=exit_code, + status=status, + ) + + def get_runs(self, project: str) -> list[RunInfo]: + """List all runs in a project. + + Args: + project: Project name + + Returns: + List of RunInfo objects sorted by name + + Raises: + ValueError: If project name is invalid + ProjectNotFoundError: If project does not exist + """ + validate_name(project, "project name") + + project_dir = self.data_dir / project + validate_safe_path(project_dir, self.data_dir) + + if not project_dir.exists(): + raise ProjectNotFoundError(f"Project '{project}' not found") + + runs = [] + seen_run_names: set[str] = set() + + # Process .jsonl files (including .wal.jsonl for Polars backend). + # Iterate the glob generator directly without list() to avoid + # materialising all paths upfront. TOCTOU races (file deleted + # between discovery and read) are handled by _read_run_info, + # which catches FileNotFoundError on stat(). + for run_file in project_dir.glob("*.jsonl"): + # Determine run name from file + if run_file.name.endswith(".wal.jsonl"): + # Skip WAL files - they're handled by metadata + continue + else: + run_name = run_file.stem + + # Skip if we've already processed this run + if run_name in seen_run_names: + continue + seen_run_names.add(run_name) + + # Handle plain JSONL files + run = self._read_run_info(project, run_name, run_file) + runs.append(run) + + return sorted(runs, key=lambda r: r.name) + + def get(self, project: str, run: str) -> RunInfo: + """Get a specific run. + + Args: + project: Project name + run: Run name + + Returns: + RunInfo object + + Raises: + ValueError: If project or run name is invalid + ProjectNotFoundError: If project does not exist + RunNotFoundError: If run does not exist + """ + validate_name(project, "project name") + validate_name(run, "run name") + + project_dir = self.data_dir / project + validate_safe_path(project_dir, self.data_dir) + + if not project_dir.exists(): + raise ProjectNotFoundError(f"Project '{project}' not found") + + # Check for JSONL file + jsonl_file = project_dir / f"{run}.jsonl" + + if jsonl_file.exists(): + return self._read_run_info(project, run, jsonl_file) + else: + raise RunNotFoundError(f"Run '{run}' not found in project '{project}'") + + def delete(self, project: str, run: str) -> None: + """Delete a run and its artifacts. + + Args: + project: Project name + run: Run name to delete + + Raises: + ValueError: If project or run name is empty or invalid + ProjectNotFoundError: If project does not exist + RunNotFoundError: If run does not exist + PermissionError: If deletion is not permitted + """ + if not project: + raise ValueError("Project name cannot be empty") + if not run: + raise ValueError("Run name cannot be empty") + + validate_name(project, "project name") + validate_name(run, "run name") + + project_dir = self.data_dir / project + validate_safe_path(project_dir, self.data_dir) + + if not project_dir.exists(): + raise ProjectNotFoundError(f"Project '{project}' does not exist") + + # Check for any run files + wal_file = project_dir / f"{run}.wal.jsonl" + jsonl_file = project_dir / f"{run}.jsonl" + + if not wal_file.exists() and not jsonl_file.exists(): + raise RunNotFoundError(f"Run '{run}' does not exist in project '{project}'") + + try: + # Delete all run-related files + metadata_file = project_dir / f"{run}.meta.json" + for file_path in [wal_file, jsonl_file, metadata_file]: + if file_path.exists(): + file_path.unlink() + logger.debug(f"Deleted file: {file_path}") + + # Delete artifacts directory if it exists + artifacts_dir = project_dir / run / "artifacts" + run_dir = project_dir / run + + if artifacts_dir.exists(): + shutil.rmtree(artifacts_dir) + logger.debug(f"Deleted artifacts for {project}/{run}") + + # Delete run directory if it exists and is empty + if run_dir.exists(): + try: + run_dir.rmdir() + logger.debug(f"Deleted run directory for {project}/{run}") + except OSError: + pass + + logger.info(f"Successfully deleted run: {project}/{run}") + except (PermissionError, OSError) as e: + logger.error(f"Error deleting run {project}/{run}: {type(e).__name__}") + raise + + def exists(self, project: str, run: str) -> bool: + """Check if a run exists. + + Args: + project: Project name + run: Run name + + Returns: + True if run exists, False otherwise + """ + try: + validate_name(project, "project name") + validate_name(run, "run name") + + project_dir = self.data_dir / project + validate_safe_path(project_dir, self.data_dir) + + wal_file = project_dir / f"{run}.wal.jsonl" + jsonl_file = project_dir / f"{run}.jsonl" + + return wal_file.exists() or jsonl_file.exists() + except ValueError: + return False + + async def subscribe( + self, + targets: Mapping[str, list[str] | None], + since: datetime, + ) -> AsyncGenerator[MetricRecord | StatusRecord, None]: + """Subscribe to file changes for specified targets using DataDirWatcher. + + This method uses a singleton DataDirWatcher instance to minimize inotify + file descriptor usage. Multiple SSE connections share the same watcher. + + Args: + targets: Dictionary mapping project names to list of run names. + If run list is None, all runs in the project are watched. + since: Filter to only yield records with timestamp >= since + + Yields: + MetricRecord or StatusRecord as files are updated + """ + from aspara.catalog.watcher import DataDirWatcher + + watcher = await DataDirWatcher.get_instance(self.data_dir) + async for record in watcher.subscribe(targets, since): + yield record + + def get_artifacts(self, project: str, run: str) -> list[dict]: + """Get artifacts for a run from metadata file. + + Args: + project: Project name + run: Run name + + Returns: + List of artifact dictionaries + """ + validate_name(project, "project name") + validate_name(run, "run name") + + # Read from metadata file + metadata_file = self.data_dir / project / f"{run}.meta.json" + validate_safe_path(metadata_file, self.data_dir) + + if metadata_file.exists(): + try: + with open(metadata_file) as f: + metadata = json.load(f) + return metadata.get("artifacts", []) + except Exception as e: + logger.warning(f"Error reading artifacts from metadata file for {run}: {e}") + + return [] + + def get_metadata(self, project: str, run: str) -> dict: + """Get run metadata from .meta.json file. + + Args: + project: Project name + run: Run name + + Returns: + Dictionary containing run metadata + """ + storage = RunMetadataStorage(self.data_dir, project, run) + return storage.get_metadata() + + def update_metadata(self, project: str, run: str, metadata: dict) -> dict: + """Update run metadata in .meta.json file. + + Args: + project: Project name + run: Run name + metadata: Dictionary with fields to update (notes, tags) + + Returns: + Updated complete metadata dictionary + """ + storage = RunMetadataStorage(self.data_dir, project, run) + return storage.update_metadata(metadata) + + def delete_metadata(self, project: str, run: str) -> bool: + """Delete run metadata file. + + Args: + project: Project name + run: Run name + + Returns: + True if file was deleted, False if it didn't exist + """ + storage = RunMetadataStorage(self.data_dir, project, run) + return storage.delete_metadata() + + def _guess_artifact_category(self, filename: str) -> str: + """Guess artifact category from file extension. + + Args: + filename: Name of the artifact file + + Returns: + Category string + """ + ext = filename.lower().split(".")[-1] if "." in filename else "" + + if ext in ["py", "js", "ts", "jsx", "tsx", "cpp", "c", "h", "java", "go", "rs", "rb", "php"]: + return "code" + if ext in ["yaml", "yml", "json", "toml", "ini", "cfg", "conf", "env"]: + return "config" + if ext in ["pt", "pth", "pkl", "pickle", "h5", "hdf5", "onnx", "pb", "tflite", "joblib"]: + return "model" + if ext in ["csv", "tsv", "parquet", "feather", "xlsx", "xls", "hdf", "npy", "npz"]: + return "data" + + return "other" + + def load_metrics( + self, + project: str, + run: str, + start_time: datetime | None = None, + ) -> pl.DataFrame: + """Load metrics for a run in wide format (auto-detects storage backend). + + Args: + project: Project name + run: Run name + start_time: Optional start time to filter metrics from + + Returns: + Polars DataFrame in wide format with columns: + - timestamp: Datetime + - step: Int64 + - _: Float64 for each metric (underscore-prefixed) + + Raises: + ValueError: If project or run name is invalid + RunNotFoundError: If run does not exist + """ + validate_name(project, "project name") + validate_name(run, "run name") + + # Create storage using factory function and load metrics + storage = _open_metrics_storage(self.data_dir, project, run) + + try: + df = storage.load() + except Exception as e: + logger.warning(f"Failed to load metrics for {project}/{run}: {e}") + return pl.DataFrame( + schema={ + "timestamp": pl.Datetime, + "step": pl.Int64, + } + ) + + # Apply start_time filter if specified + if start_time is not None and len(df) > 0: + df = df.filter(pl.col("timestamp") >= start_time) + + return df + + def get_run_config(self, project: str, run: str) -> dict[str, Any]: + """Get run config from .meta.json file. + + This reads the .meta.json file which contains params, config, status, etc. + Different from get_metadata which uses ProjectMetadataStorage for notes/tags. + + Args: + project: Project name + run: Run name + + Returns: + Dictionary containing run config (params, config, status, etc.) + """ + validate_name(project, "project name") + validate_name(run, "run name") + + metadata_file = self.data_dir / project / f"{run}.meta.json" + validate_safe_path(metadata_file, self.data_dir) + + return _read_metadata_file(metadata_file) + + async def get_run_config_async(self, project: str, run: str) -> dict[str, Any]: + """Get run config asynchronously using run_in_executor. + + This reads the .meta.json file which contains params, config, status, etc. + + Args: + project: Project name + run: Run name + + Returns: + Dictionary containing run config (params, config, status, etc.) + """ + return await asyncio.to_thread(self.get_run_config, project, run) + + async def get_metadata_async(self, project: str, run: str) -> dict[str, Any]: + """Get run metadata asynchronously using run_in_executor. + + Args: + project: Project name + run: Run name + + Returns: + Dictionary containing run metadata (tags, notes, params, etc.) + """ + return await asyncio.to_thread(self.get_metadata, project, run) + + async def get_artifacts_async(self, project: str, run: str) -> list[dict[str, Any]]: + """Get artifacts for a run asynchronously using run_in_executor. + + Args: + project: Project name + run: Run name + + Returns: + List of artifact dictionaries + """ + return await asyncio.to_thread(self.get_artifacts, project, run) diff --git a/src/aspara/catalog/watcher.py b/src/aspara/catalog/watcher.py new file mode 100644 index 0000000000000000000000000000000000000000..25502e04d1dd19de2069558fd05662b1bee6bd39 --- /dev/null +++ b/src/aspara/catalog/watcher.py @@ -0,0 +1,558 @@ +""" +DataDirWatcher - Singleton watcher for data directory. + +This module provides a centralized file watcher service that uses a single +inotify watcher for the entire data directory. Multiple SSE connections +subscribe to this service, reducing inotify file descriptor usage. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import uuid +from collections.abc import AsyncGenerator, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from watchfiles import awatch + +from aspara.models import MetricRecord, RunStatus, StatusRecord +from aspara.utils.timestamp import parse_to_datetime +from aspara.utils.validators import validate_name + +logger = logging.getLogger(__name__) + + +@dataclass +class Subscription: + """Subscription to data directory changes.""" + + id: str + targets: Mapping[str, list[str] | None] # project -> runs (None means all runs) + since: datetime + queue: asyncio.Queue[MetricRecord | StatusRecord | None] = field(default_factory=asyncio.Queue) + + +class DataDirWatcher: + """Singleton watcher for data directory. + + This class provides a single inotify watcher for the entire data directory, + allowing multiple SSE connections to subscribe without consuming additional + file descriptors. + """ + + # Size thresholds for initial read strategy + LARGE_FILE_THRESHOLD = 1 * 1024 * 1024 # 1MB + TAIL_READ_SIZE = 64 * 1024 # Read last 64KB for large files + + _instance: DataDirWatcher | None = None + _lock: asyncio.Lock | None = None + + def __init__(self, data_dir: Path) -> None: + """Initialize the watcher. + + Note: Use get_instance() to get the singleton instance. + + Args: + data_dir: Base directory for data storage + """ + # Resolve to absolute path for consistent comparison with awatch paths + self.data_dir = data_dir.resolve() + self._subscriptions: dict[str, Subscription] = {} + self._task: asyncio.Task[None] | None = None + self._instance_lock = asyncio.Lock() + # Track file sizes for incremental reading + self._file_sizes: dict[Path, int] = {} + # Track run statuses for change detection + self._run_statuses: dict[tuple[str, str], str | None] = {} + + @classmethod + async def get_instance(cls, data_dir: Path) -> DataDirWatcher: + """Get or create singleton instance. + + Args: + data_dir: Base directory for data storage + + Returns: + DataDirWatcher singleton instance + """ + if cls._lock is None: + cls._lock = asyncio.Lock() + + async with cls._lock: + if cls._instance is None: + cls._instance = cls(data_dir) + logger.info(f"[Watcher] Created singleton DataDirWatcher for {data_dir}") + return cls._instance + + @classmethod + def reset_instance(cls) -> None: + """Reset the singleton instance. Used for testing.""" + cls._instance = None + cls._lock = None + + @classmethod + async def shutdown(cls) -> None: + """Properly shut down the singleton instance. + + Cancels the running dispatch task (which closes the underlying + awatch/inotify file descriptor) and then clears the singleton + state via reset_instance(). Call this from the application + lifespan shutdown so that a subsequent reload does not reuse a + stale watcher — which would leak inotify FDs and deliver + duplicate events. + """ + instance = cls._instance + if instance is not None and instance._task is not None and not instance._task.done(): + logger.info("[Watcher] Shutting down dispatch task") + instance._task.cancel() + with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError): + await asyncio.wait_for(instance._task, timeout=2.0) + cls.reset_instance() + + def _parse_file_path(self, file_path: Path) -> tuple[str, str, str] | None: + """Parse file path to extract project, run name, and file type. + + Args: + file_path: Absolute path to a file + + Returns: + (project, run_name, file_type) where file_type is 'metrics', 'wal', or 'meta' + None if path doesn't match expected pattern or names are invalid + """ + try: + relative = file_path.relative_to(self.data_dir) + except ValueError: + return None + + parts = relative.parts + if len(parts) != 2: + return None + + project = parts[0] + filename = parts[1] + + if filename.endswith(".wal.jsonl"): + run_name = filename[:-10] + file_type = "wal" + elif filename.endswith(".meta.json"): + run_name = filename[:-10] + file_type = "meta" + elif filename.endswith(".jsonl"): + run_name = filename[:-6] + file_type = "metrics" + else: + return None + + # Validate project and run names so that reserved/hidden directories + # (e.g. .queue) or names with path-traversal characters are ignored + # at the watcher level rather than dispatched to subscribers and + # later rejected by the API layer. + try: + validate_name(project, "project name") + validate_name(run_name, "run name") + except ValueError: + return None + + return (project, run_name, file_type) + + def _parse_metric_line(self, line: str, project: str, run: str, since: datetime) -> MetricRecord | None: + """Parse a JSONL line and return MetricRecord if it passes the since filter. + + Args: + line: A single line from a JSONL file + project: Project name + run: Run name + since: Filter timestamp - only records with timestamp >= since are returned + + Returns: + MetricRecord if parsing succeeds and passes filter, None otherwise + """ + if not line.strip(): + return None + try: + entry = json.loads(line) + ts_value = entry.get("timestamp") + record_ts = None + if ts_value is not None: + with contextlib.suppress(ValueError): + record_ts = parse_to_datetime(ts_value) + if record_ts is None or record_ts >= since: + entry["run"] = run + entry["project"] = project + return MetricRecord(**entry) + except Exception as e: + logger.debug(f"[Watcher] Error parsing line: {e}") + return None + + def _read_file_with_strategy(self, file_path: Path) -> tuple[str, int]: + """Read file content with size-based strategy. + + For large files, only the tail portion is read to improve initial load time. + + Args: + file_path: Path to the file to read + + Returns: + Tuple of (content, end_position) where end_position is the file position after reading + """ + file_size = file_path.stat().st_size + + if file_size < self.LARGE_FILE_THRESHOLD: + with open(file_path) as f: + content = f.read() + return content, f.tell() + + # Large file: read tail only + logger.debug(f"[Watcher] Large file ({file_size} bytes), reading tail: {file_path}") + with open(file_path) as f: + read_start = max(0, file_size - self.TAIL_READ_SIZE) + f.seek(read_start) + content = f.read() + end_pos = f.tell() + + # Skip partial first line if we didn't start at beginning + if read_start > 0: + first_newline = content.find("\n") + if first_newline != -1: + content = content[first_newline + 1 :] + + return content, end_pos + + def _init_run_status(self, project: str, run: str, meta_file: Path) -> None: + """Initialize run status tracking from meta file. + + Args: + project: Project name + run: Run name + meta_file: Path to the metadata file + """ + key = (project, run) + if meta_file.exists(): + try: + with open(meta_file) as f: + meta = json.load(f) + self._run_statuses[key] = meta.get("status") + except Exception: + self._run_statuses[key] = None + else: + self._run_statuses[key] = None + + def _matches_targets(self, targets: Mapping[str, list[str] | None], project: str, run: str) -> bool: + """Check if a project/run matches the subscription targets. + + Args: + targets: Subscription targets + project: Project name + run: Run name + + Returns: + True if the project/run matches the targets + """ + if project not in targets: + return False + + run_list = targets[project] + if run_list is None: + # None means watch all runs in the project + return True + + return run in run_list + + async def _read_initial_data( + self, + targets: Mapping[str, list[str] | None], + since: datetime, + ) -> AsyncGenerator[MetricRecord | StatusRecord, None]: + """Read initial data from existing files. + + Args: + targets: Dictionary mapping project names to run lists + since: Filter to only yield records with timestamp >= since + + Yields: + MetricRecord objects from existing files + """ + for project, run_names in targets.items(): + try: + validate_name(project, "project name") + except ValueError as e: + logger.warning(f"[Watcher] Invalid project name {project}: {e}") + continue + + project_dir = self.data_dir / project + if not project_dir.exists(): + logger.warning(f"[Watcher] Project directory does not exist: {project_dir}") + continue + + # If run_names is None, discover all runs + if run_names is None: + actual_runs = [] + for f in project_dir.glob("*.jsonl"): + if f.name.endswith(".wal.jsonl"): + continue + # Skip symlinks to prevent symlink-based attacks + if f.is_symlink(): + logger.warning(f"[Watcher] Skipping symlink: {f}") + continue + actual_runs.append(f.stem) + run_names = actual_runs + + for run in run_names: + # Check which files exist for this run + wal_file = project_dir / f"{run}.wal.jsonl" + jsonl_file = project_dir / f"{run}.jsonl" + meta_file = project_dir / f"{run}.meta.json" + + # Initialize status tracking + self._init_run_status(project, run, meta_file) + + # Read metrics files + for file_path in [wal_file, jsonl_file]: + if not file_path.exists(): + continue + + resolved = file_path.resolve() + + try: + content, end_pos = self._read_file_with_strategy(resolved) + self._file_sizes[resolved] = end_pos + + for line in content.splitlines(): + record = self._parse_metric_line(line, project, run, since) + if record is not None: + yield record + except Exception as e: + logger.warning(f"[Watcher] Error reading {resolved}: {e}") + if resolved.exists(): + self._file_sizes[resolved] = resolved.stat().st_size + + # Record meta file size + if meta_file.exists(): + self._file_sizes[meta_file.resolve()] = meta_file.stat().st_size + + async def _dispatch_loop(self) -> None: + """Main loop: watch data_dir and dispatch to subscribers.""" + logger.info(f"[Watcher] Starting dispatch loop for {self.data_dir}") + watcher = None + + try: + watcher = awatch(str(self.data_dir)) + loop_count = 0 + async for changes in watcher: + loop_count += 1 + if loop_count % 10000 == 0: + logger.warning(f"[Watcher] Loop count: {loop_count}, changes: {len(changes)}") + logger.debug(f"[Watcher] Received {len(changes)} change(s)") + + for _change_type, changed_path_str in changes: + raw_path = Path(changed_path_str) + # Skip symlinks to prevent reading files outside data_dir. + # The initial read in _read_initial_data already skips + # symlinks; the dispatch loop must do the same so a + # symlink created after subscription cannot bypass it. + if raw_path.is_symlink(): + logger.warning(f"[Watcher] Skipping symlink in dispatch: {raw_path}") + continue + + changed_path = raw_path.resolve() + + # Parse file path to get project/run/type + parsed = self._parse_file_path(changed_path) + if parsed is None: + continue + + project, run, file_type = parsed + logger.debug(f"[Watcher] File change: {changed_path} (project={project}, run={run}, type={file_type})") + + # Dispatch to matching subscribers + async with self._instance_lock: + for sub in self._subscriptions.values(): + if not self._matches_targets(sub.targets, project, run): + continue + + try: + if file_type == "meta": + # Handle metadata/status update + status_record = await self._process_meta_change(changed_path, project, run) + if status_record: + await sub.queue.put(status_record) + else: + # Handle metrics update + metric_records = await self._process_metrics_change(changed_path, project, run, sub.since) + for metric_record in metric_records: + await sub.queue.put(metric_record) + except Exception as e: + logger.error(f"[Watcher] Error dispatching to subscription: {e}") + + except asyncio.CancelledError: + logger.info("[Watcher] Dispatch loop cancelled") + raise + except Exception as e: + logger.error(f"[Watcher] Error in dispatch loop: {e}") + finally: + if watcher is not None: + logger.info("[Watcher] Closing awatch instance") + try: + await asyncio.wait_for(watcher.aclose(), timeout=2.0) + except asyncio.TimeoutError: + logger.warning("[Watcher] Timeout closing awatch instance") + except Exception as e: + logger.error(f"[Watcher] Error closing watcher: {e}") + + async def _process_meta_change(self, file_path: Path, project: str, run: str) -> StatusRecord | None: + """Process a metadata file change. + + Args: + file_path: Path to the metadata file + project: Project name + run: Run name + + Returns: + StatusRecord if status changed, None otherwise + """ + try: + with open(file_path) as f: + meta = json.load(f) + new_status = meta.get("status") + + key = (project, run) + if new_status != self._run_statuses.get(key): + logger.info(f"[Watcher] Status change for {project}/{run}: {self._run_statuses.get(key)} -> {new_status}") + self._run_statuses[key] = new_status + + return StatusRecord( + run=run, + project=project, + status=new_status or RunStatus.WIP.value, + is_finished=meta.get("is_finished", False), + exit_code=meta.get("exit_code"), + ) + except Exception as e: + logger.error(f"[Watcher] Error reading metadata file {file_path}: {e}") + + return None + + async def _process_metrics_change(self, file_path: Path, project: str, run: str, since: datetime) -> list[MetricRecord]: + """Process a metrics file change. + + Args: + file_path: Path to the metrics file + project: Project name + run: Run name + since: Filter timestamp + + Returns: + List of MetricRecord objects + """ + records: list[MetricRecord] = [] + + try: + # Determine where to resume reading. The tracked size may be stale + # if the file was truncated (e.g. PolarsMetricsStorage._clear_wal + # truncates the WAL to 0 bytes after archiving) or replaced. When + # the actual size is smaller than what we last read, rewind to the + # beginning so the newly appended content is not silently skipped. + actual_size = file_path.stat().st_size + tracked_size = self._file_sizes.get(file_path, 0) + read_from = 0 if actual_size < tracked_size else tracked_size + + with open(file_path) as f: + f.seek(read_from) + new_content = f.read() + self._file_sizes[file_path] = f.tell() + + for line in new_content.splitlines(): + record = self._parse_metric_line(line, project, run, since) + if record is not None: + records.append(record) + + except Exception as e: + logger.error(f"[Watcher] Error processing metrics file {file_path}: {e}") + + return records + + async def subscribe( + self, + targets: Mapping[str, list[str] | None], + since: datetime, + ) -> AsyncGenerator[MetricRecord | StatusRecord, None]: + """Subscribe to file changes for specified targets. + + Args: + targets: Dictionary mapping project names to list of run names. + If run list is None, all runs in the project are watched. + since: Filter to only yield records with timestamp >= since + + Yields: + MetricRecord or StatusRecord as files are updated + """ + # Ensure since is timezone-aware + if since.tzinfo is None: + since = since.replace(tzinfo=timezone.utc) + + subscription_id = str(uuid.uuid4()) + queue: asyncio.Queue[MetricRecord | StatusRecord | None] = asyncio.Queue() + + subscription = Subscription( + id=subscription_id, + targets=targets, + since=since, + queue=queue, + ) + + logger.info(f"[Watcher] New subscription {subscription_id} for targets={targets}") + + async with self._instance_lock: + self._subscriptions[subscription_id] = subscription + # Start watcher task if not running + if self._task is None or self._task.done(): + logger.info("[Watcher] Starting dispatch task") + self._task = asyncio.create_task(self._dispatch_loop()) + + try: + # Yield initial data (existing records >= since) + async for record in self._read_initial_data(targets, since): + yield record + + # Yield updates from queue + while True: + queued_record: MetricRecord | StatusRecord | None = await queue.get() + if queued_record is None: # Sentinel for unsubscribe + break + yield queued_record + finally: + await self._unsubscribe(subscription_id) + + async def _unsubscribe(self, subscription_id: str) -> None: + """Unsubscribe from file changes. + + Args: + subscription_id: Subscription ID to remove + """ + logger.info(f"[Watcher] Unsubscribing {subscription_id}") + + async with self._instance_lock: + if subscription_id in self._subscriptions: + del self._subscriptions[subscription_id] + + # Stop watcher task if no more subscribers + if not self._subscriptions and self._task is not None: + logger.info("[Watcher] No more subscribers, stopping dispatch task") + self._task.cancel() + try: + await asyncio.wait_for(self._task, timeout=2.0) + except asyncio.TimeoutError: + logger.warning("[Watcher] Timeout waiting for dispatch task to finish") + except asyncio.CancelledError: + pass + self._task = None + + @property + def subscription_count(self) -> int: + """Get the number of active subscriptions.""" + return len(self._subscriptions) diff --git a/src/aspara/cli.py b/src/aspara/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..22f6f21f741f4f5fe58cd0a77c8705a8527f7eaa --- /dev/null +++ b/src/aspara/cli.py @@ -0,0 +1,568 @@ +#!/usr/bin/env python3 +""" +Aspara CLI tool + +Command line interface for starting dashboard and tracker API +""" + +from __future__ import annotations + +import argparse +import os +import socket +import sys +import tempfile +from importlib.metadata import version as _pkg_version +from pathlib import Path + +import uvicorn + +from aspara.config import _validate_data_dir, get_data_dir, get_storage_backend + + +def _get_version() -> str: + """Get the installed aspara version. + + Reads package metadata (sourced from pyproject.toml) so the CLI does not + need to import the aspara package (and its heavy dependencies) just to + print ``--version``. + """ + try: + return _pkg_version("aspara") + except Exception: + # Fallback for environments without installed metadata (e.g. running + # directly from a source checkout without install). + from aspara import __version__ + + return __version__ + + +def _resolve_and_validate_data_dir(data_dir: str | None, *, require_writable: bool = True) -> str: + """Resolve *data_dir* to an absolute path and validate it. + + Performs the following checks (in order): + + 1. Forbidden system-path check (delegates to ``config._validate_data_dir``). + 2. Parent directory exists (so the path is creatable). + 3. If *require_writable* is ``True``, the directory (or its parent when + it does not yet exist) is writable. + + On any failure an error message is printed to stdout and ``sys.exit(1)`` + is called. + + Args: + data_dir: Raw ``--data-dir`` value. ``None`` falls back to + ``get_data_dir()``. + require_writable: When ``True`` (server commands), verify write + access. Read-only commands (``projects``, ``runs``) pass + ``False``. + + Returns: + The resolved absolute path as a ``str``. + """ + if data_dir is None: + return str(get_data_dir()) + + raw_path = Path(data_dir).expanduser() + resolved = raw_path.resolve() + + # 1. Forbidden system-path check + try: + _validate_data_dir(resolved) + except ValueError as exc: + print(f"Error: {exc}") + sys.exit(1) + + # 2. Parent directory must exist (so the path is creatable) + parent = resolved if resolved.exists() else resolved.parent + if not parent.exists(): + print(f"Error: data directory parent does not exist: {parent}") + print("Hint: create the parent directory first, e.g.:") + print(f" mkdir -p {parent}") + sys.exit(1) + + # 3. Writable check (for server commands that write data) + if require_writable: + test_dir = resolved if resolved.exists() else parent + try: + with tempfile.TemporaryFile(dir=str(test_dir), prefix=".aspara_write_test_"): + pass + except (PermissionError, OSError) as exc: + print(f"Error: data directory is not writable: {test_dir}") + print(f" {exc}") + sys.exit(1) + + return str(resolved) + + +def parse_serve_components(components: list[str]) -> tuple[bool, bool]: + """ + Parse and validate component list for serve command + + Args: + components: List of component names + + Returns: + Tuple of (enable_dashboard, enable_tracker) + + Raises: + ValueError: If invalid component name is provided + """ + valid_components = {"dashboard", "tracker", "together"} + + # Default: dashboard only + if not components: + return (True, False) + + # Normalize and validate + normalized = [c.lower() for c in components] + for comp in normalized: + if comp not in valid_components: + raise ValueError(f"Invalid component: {comp}. Valid options are: dashboard, tracker, together") + + # Handle 'together' keyword + if "together" in normalized: + return (True, True) + + # Handle explicit component list + enable_dashboard = "dashboard" in normalized + enable_tracker = "tracker" in normalized + + # If both specified, enable both + if enable_dashboard and enable_tracker: + return (True, True) + + return (enable_dashboard, enable_tracker) + + +def get_default_port(enable_dashboard: bool, enable_tracker: bool) -> int: + """ + Get default port based on enabled components + + Args: + enable_dashboard: Whether dashboard is enabled + enable_tracker: Whether tracker is enabled + + Returns: + Default port number (3142 for tracker-only, 3141 otherwise) + """ + if enable_tracker and not enable_dashboard: + return 3142 + return 3141 + + +def find_available_port(start_port: int = 3141, max_attempts: int = 100) -> int | None: + """ + Find an available port number + + Args: + start_port: Starting port number + max_attempts: Maximum number of attempts + + Returns: + Available port number, None if not found + """ + for port in range(start_port, start_port + max_attempts): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + # If connection fails, that port is available + result = sock.connect_ex(("127.0.0.1", port)) + if result != 0: + return port + return None + + +def _warn_wildcard_host(host: str) -> None: + """Warn when binding to a wildcard address without read-only mode. + + Binding to ``0.0.0.0`` or ``::`` exposes the server to the entire + network. Since Aspara has no authentication, anyone on the network + can read, modify, or delete all data. This helper prints a warning + recommending read-only mode in that case. + + Args: + host: Host string passed via ``--host`` + """ + if host not in ("0.0.0.0", "::", ""): + return + + print("WARNING: binding to a wildcard address exposes the server") + print("to the entire network. Aspara has no authentication, so") + print("anyone on the network can read, modify, or delete all data.") + if os.environ.get("ASPARA_READ_ONLY") != "1": + print("Consider enabling read-only mode:") + print(" ASPARA_READ_ONLY=1 aspara dashboard --host 0.0.0.0") + print() + + +def run_dashboard( + host: str = "127.0.0.1", + port: int = 3141, + with_tracker: bool = False, + data_dir: str | None = None, + dev: bool = False, + project_search_mode: str = "realtime", +) -> None: + """ + Start dashboard server + + Args: + host: Host name + port: Port number + with_tracker: Whether to run integrated tracker in same process + data_dir: Data directory for local data + dev: Enable development mode with auto-reload + project_search_mode: Project search mode on dashboard home (realtime or manual) + """ + # Set env vars for component mounting + os.environ["ASPARA_SERVE_DASHBOARD"] = "1" + os.environ["ASPARA_SERVE_TRACKER"] = "1" if with_tracker else "0" + + if with_tracker: + os.environ["ASPARA_WITH_TRACKER"] = "1" + + if dev: + os.environ["ASPARA_DEV_MODE"] = "1" + + data_dir = _resolve_and_validate_data_dir(data_dir, require_writable=True) + + os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir) + + if project_search_mode: + os.environ["ASPARA_PROJECT_SEARCH_MODE"] = project_search_mode + + from aspara.dashboard.router import configure_data_dir + + configure_data_dir(data_dir) + + _warn_wildcard_host(host) + print("Starting Aspara Dashboard server...") + print(f"Access http://{host}:{port} in your browser!") + print(f"Data directory: {os.path.abspath(data_dir)}") + backend = get_storage_backend() or "jsonl (default)" + print(f"Storage backend: {backend}") + if dev: + print("Development mode: auto-reload enabled") + + try: + uvicorn.run("aspara.server:app", host=host, port=port, reload=dev) + except ImportError: + print("Error: Dashboard functionality is not installed!") + print('To install: uv pip install "aspara[dashboard]"') + sys.exit(1) + + +def run_tui(data_dir: str | None = None) -> None: + """ + Start TUI dashboard + + Args: + data_dir: Data directory. Defaults to XDG-based default (~/.local/share/aspara) + """ + data_dir = _resolve_and_validate_data_dir(data_dir, require_writable=True) + + print("Starting Aspara TUI...") + print(f"Data directory: {os.path.abspath(data_dir)}") + + try: + from aspara.tui import run_tui as _run_tui + + _run_tui(data_dir=data_dir) + except ImportError: + print("TUI functionality is not installed!") + print('To install: uv pip install "aspara[tui]"') + sys.exit(1) + + +def run_tracker( + host: str = "127.0.0.1", + port: int = 3142, + data_dir: str | None = None, + dev: bool = False, + storage_backend: str | None = None, +) -> None: + """ + Start tracker API server + + Args: + host: Host name + port: Port number + data_dir: Data directory. Defaults to XDG-based default (~/.local/share/aspara) + dev: Enable development mode with auto-reload + storage_backend: Metrics storage backend (jsonl or polars) + """ + # Set env vars for backward compatibility + os.environ["ASPARA_SERVE_TRACKER"] = "1" + os.environ["ASPARA_SERVE_DASHBOARD"] = "0" + + if dev: + os.environ["ASPARA_DEV_MODE"] = "1" + + if storage_backend is not None: + os.environ["ASPARA_STORAGE_BACKEND"] = storage_backend + + data_dir = _resolve_and_validate_data_dir(data_dir, require_writable=True) + + os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir) + + _warn_wildcard_host(host) + print("Starting Aspara Tracker API server...") + print(f"Endpoint: http://{host}:{port}/tracker/api/v1") + print(f"Data directory: {os.path.abspath(data_dir)}") + backend = get_storage_backend() or "jsonl (default)" + print(f"Storage backend: {backend}") + if dev: + print("Development mode: auto-reload enabled") + + try: + uvicorn.run("aspara.server:app", host=host, port=port, reload=dev) + except ImportError: + print("Error: Tracker functionality is not installed!") + print('To install: uv pip install "aspara[tracker]"') + sys.exit(1) + + +def run_serve( + components: list[str], + host: str = "127.0.0.1", + port: int | None = None, + data_dir: str | None = None, + dev: bool = False, + project_search_mode: str = "realtime", + storage_backend: str | None = None, +) -> None: + """ + Start Aspara server with specified components + + Args: + components: List of components to enable (dashboard, tracker, together) + host: Host name + port: Port number (auto-detected if None) + data_dir: Data directory + dev: Enable development mode with auto-reload + project_search_mode: Project search mode on dashboard home (realtime or manual) + storage_backend: Metrics storage backend (jsonl or polars) + """ + try: + enable_dashboard, enable_tracker = parse_serve_components(components) + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) + + # Set environment variables for component mounting + os.environ["ASPARA_SERVE_DASHBOARD"] = "1" if enable_dashboard else "0" + os.environ["ASPARA_SERVE_TRACKER"] = "1" if enable_tracker else "0" + + if dev: + os.environ["ASPARA_DEV_MODE"] = "1" + + if storage_backend is not None: + os.environ["ASPARA_STORAGE_BACKEND"] = storage_backend + + # Determine port + if port is None: + port = get_default_port(enable_dashboard, enable_tracker) + + # Configure data directory + data_dir = _resolve_and_validate_data_dir(data_dir, require_writable=True) + + os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir) + + # Configure dashboard if enabled + if enable_dashboard: + if project_search_mode: + os.environ["ASPARA_PROJECT_SEARCH_MODE"] = project_search_mode + + from aspara.dashboard.router import configure_data_dir + + configure_data_dir(data_dir) + + # Build component description + if enable_dashboard and enable_tracker: + component_desc = "Dashboard + Tracker" + elif enable_dashboard: + component_desc = "Dashboard" + else: + component_desc = "Tracker" + + _warn_wildcard_host(host) + print(f"Starting Aspara {component_desc} server...") + print(f"Access http://{host}:{port} in your browser!") + print(f"Data directory: {os.path.abspath(data_dir)}") + backend = get_storage_backend() or "jsonl (default)" + print(f"Storage backend: {backend}") + if dev: + print("Development mode: auto-reload enabled") + + try: + uvicorn.run("aspara.server:app", host=host, port=port, reload=dev) + except ImportError as e: + print(f"Error: Required functionality is not installed: {e}") + sys.exit(1) + + +def _list_projects(data_dir: str | None) -> None: + """Print all projects and their run counts.""" + from aspara.catalog import ProjectCatalog + + resolved_dir = _resolve_and_validate_data_dir(data_dir, require_writable=False) + catalog = ProjectCatalog(resolved_dir) + projects = catalog.get_projects() + + if not projects: + print("No projects found. Use `aspara.init(project=...)` to create one.") + return + + # Column widths for alignment + name_w = max(len(p.name) for p in projects) + print(f"{'PROJECT':<{name_w}} RUNS LAST UPDATED") + print(f"{'-' * name_w} ---- ------------") + for p in projects: + last = p.last_update.strftime("%Y-%m-%d %H:%M") if p.last_update else "N/A" + print(f"{p.name:<{name_w}} {p.run_count:>4} {last}") + + +def _list_runs(project: str, data_dir: str | None) -> int: + """Print all runs in a project. + + Returns 0 on success, 1 if the project does not exist. + """ + from aspara.catalog import ProjectCatalog, RunCatalog + + resolved_dir = _resolve_and_validate_data_dir(data_dir, require_writable=False) + project_catalog = ProjectCatalog(resolved_dir) + if not project_catalog.exists(project): + print(f"Project '{project}' not found in {resolved_dir}") + return 1 + + run_catalog = RunCatalog(resolved_dir) + runs = run_catalog.get_runs(project) + + if not runs: + print(f"No runs found in project '{project}'.") + return 0 + + status_display = { + "wip": "Running", + "completed": "Completed", + "failed": "Failed", + "maybe_failed": "Maybe Failed", + } + + name_w = max(len(r.name) for r in runs) + print(f"{'RUN':<{name_w}} STATUS STARTED") + print(f"{'-' * name_w} -------- -------") + for r in runs: + status = status_display.get(r.status.value, r.status.value) + started = r.start_time.strftime("%Y-%m-%d %H:%M") if r.start_time else "N/A" + print(f"{r.name:<{name_w}} {status:<8} {started}") + return 0 + + +def main() -> None: + """ + CLI main entry point + """ + parser = argparse.ArgumentParser(description="Aspara management tool. Run a subcommand to start a server or the TUI.") + parser.add_argument("--version", action="version", version=f"aspara {_get_version()}") + subparsers = parser.add_subparsers(dest="command", required=True, help="Subcommands") + + dashboard_parser = subparsers.add_parser("dashboard", help="Start dashboard server") + dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)") + dashboard_parser.add_argument("--port", type=int, default=3141, help="Port number (default: 3141)") + dashboard_parser.add_argument("--with-tracker", action="store_true", help="Run dashboard with integrated tracker in same process") + dashboard_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)") + dashboard_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload") + dashboard_parser.add_argument( + "--project-search-mode", + choices=["realtime", "manual"], + default="realtime", + help="Project search mode on dashboard home (realtime or manual, default: realtime)", + ) + + tracker_parser = subparsers.add_parser("tracker", help="Start tracker API server") + tracker_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)") + tracker_parser.add_argument("--port", type=int, default=3142, help="Port number (default: 3142)") + tracker_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)") + tracker_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload") + tracker_parser.add_argument( + "--storage-backend", + choices=["jsonl", "polars"], + default=None, + help="Metrics storage backend (default: jsonl or ASPARA_STORAGE_BACKEND)", + ) + + tui_parser = subparsers.add_parser("tui", help="Start terminal UI dashboard") + tui_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)") + + serve_parser = subparsers.add_parser("serve", help="Start Aspara server") + serve_parser.add_argument( + "components", + nargs="*", + default=[], + help="Components to run: dashboard, tracker, together (default: dashboard only)", + ) + serve_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)") + serve_parser.add_argument("--port", type=int, default=None, help="Port number (default: 3141 for dashboard, 3142 for tracker-only)") + serve_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)") + serve_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload") + serve_parser.add_argument( + "--project-search-mode", + choices=["realtime", "manual"], + default="realtime", + help="Project search mode on dashboard home (realtime or manual, default: realtime)", + ) + serve_parser.add_argument( + "--storage-backend", + choices=["jsonl", "polars"], + default=None, + help="Metrics storage backend (default: jsonl or ASPARA_STORAGE_BACKEND)", + ) + + projects_parser = subparsers.add_parser("projects", help="List all projects") + projects_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)") + + runs_parser = subparsers.add_parser("runs", help="List runs in a project") + runs_parser.add_argument("project", help="Project name") + runs_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)") + + args = parser.parse_args() + + if args.command == "dashboard": + run_dashboard( + host=args.host, + port=args.port, + with_tracker=args.with_tracker, + data_dir=args.data_dir, + dev=args.dev, + project_search_mode=args.project_search_mode, + ) + elif args.command == "tracker": + run_tracker( + host=args.host, + port=args.port, + data_dir=args.data_dir, + dev=args.dev, + storage_backend=args.storage_backend, + ) + elif args.command == "tui": + run_tui(data_dir=args.data_dir) + elif args.command == "serve": + run_serve( + components=args.components, + host=args.host, + port=args.port, + data_dir=args.data_dir, + dev=args.dev, + project_search_mode=args.project_search_mode, + storage_backend=args.storage_backend, + ) + elif args.command == "projects": + _list_projects(data_dir=args.data_dir) + elif args.command == "runs": + exit_code = _list_runs(project=args.project, data_dir=args.data_dir) + if exit_code != 0: + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/src/aspara/config.py b/src/aspara/config.py new file mode 100644 index 0000000000000000000000000000000000000000..920756004a7cfe65a24c357b715340ccd809ea07 --- /dev/null +++ b/src/aspara/config.py @@ -0,0 +1,266 @@ +"""Configuration and environment handling for Aspara.""" + +import os +from pathlib import Path + +from pydantic import BaseModel, Field + +__all__ = [ + "ResourceLimits", + "SSE_METRICS_ITERATOR_CLOSE_TIMEOUT", + "get_data_dir", + "get_resource_limits", + "get_sse_dev_shutdown_timeout", + "get_sse_heartbeat_interval", + "get_sse_send_timeout", + "get_storage_backend", + "get_project_search_mode", + "is_dev_mode", + "is_read_only", +] + + +class ResourceLimits(BaseModel): + """Resource limits configuration. + + Includes security-related limits (file size, JSONL lines) and + performance/resource constraints (metric names, note length, tags count). + + All limits can be customized via environment variables. + Defaults are set for internal use with generous limits. + """ + + max_file_size: int = Field( + default=1024 * 1024 * 1024, # 1024MB (1GB) + description="Maximum file size in bytes", + ) + + max_jsonl_lines: int = Field( + default=1_000_000, # 1M lines + description="Maximum number of lines when reading JSONL files", + ) + + max_zip_size: int = Field( + default=1024 * 1024 * 1024, # 1GB + description="Maximum ZIP file size in bytes", + ) + + max_metric_names: int = Field( + default=100, + description="Maximum number of metric names in comma-separated list", + ) + + max_notes_length: int = Field( + default=10 * 1024, # 10KB + description="Maximum notes text length in characters", + ) + + max_tags_count: int = Field( + default=100, + description="Maximum number of tags", + ) + + lttb_threshold: int = Field( + default=1_000, + description="Downsample metrics using LTTB algorithm when metric series length exceeds this threshold", + ) + + @classmethod + def from_env(cls) -> "ResourceLimits": + """Create ResourceLimits from environment variables. + + Environment variables: + - ASPARA_MAX_FILE_SIZE: Maximum file size in bytes (default: 1GB) + - ASPARA_MAX_JSONL_LINES: Maximum JSONL lines (default: 1M) + - ASPARA_MAX_ZIP_SIZE: Maximum ZIP size in bytes (default: 1GB) + - ASPARA_MAX_METRIC_NAMES: Maximum metric names (default: 100) + - ASPARA_MAX_NOTES_LENGTH: Maximum notes length (default: 10KB) + - ASPARA_MAX_TAGS_COUNT: Maximum tags count (default: 100) + - ASPARA_LTTB_THRESHOLD: Threshold for LTTB downsampling (default: 1000) + """ + return cls( + max_file_size=int(os.environ.get("ASPARA_MAX_FILE_SIZE", cls.model_fields["max_file_size"].default)), + max_jsonl_lines=int(os.environ.get("ASPARA_MAX_JSONL_LINES", cls.model_fields["max_jsonl_lines"].default)), + max_zip_size=int(os.environ.get("ASPARA_MAX_ZIP_SIZE", cls.model_fields["max_zip_size"].default)), + max_metric_names=int(os.environ.get("ASPARA_MAX_METRIC_NAMES", cls.model_fields["max_metric_names"].default)), + max_notes_length=int(os.environ.get("ASPARA_MAX_NOTES_LENGTH", cls.model_fields["max_notes_length"].default)), + max_tags_count=int(os.environ.get("ASPARA_MAX_TAGS_COUNT", cls.model_fields["max_tags_count"].default)), + lttb_threshold=int(os.environ.get("ASPARA_LTTB_THRESHOLD", cls.model_fields["lttb_threshold"].default)), + ) + + +# Global resource limits instance +_resource_limits: ResourceLimits | None = None + + +def get_resource_limits() -> ResourceLimits: + """Get resource limits configuration. + + Returns cached instance if already initialized. + """ + global _resource_limits + if _resource_limits is None: + _resource_limits = ResourceLimits.from_env() + return _resource_limits + + +# Forbidden system directories that cannot be used as data directories +_FORBIDDEN_PATHS = frozenset(["/", "/etc", "/sys", "/dev", "/bin", "/sbin", "/usr", "/var", "/boot", "/proc"]) + + +def _validate_data_dir(data_path: Path) -> None: + """Validate that data directory is not a dangerous system path. + + Args: + data_path: Path to validate + + Raises: + ValueError: If path is a forbidden system directory + """ + resolved = data_path.resolve() + resolved_str = str(resolved) + + for forbidden in _FORBIDDEN_PATHS: + if resolved_str == forbidden or resolved_str.rstrip("/") == forbidden: + raise ValueError(f"ASPARA_DATA_DIR cannot be set to system directory: {forbidden}") + + +def get_data_dir() -> Path: + """Get the default data directory for Aspara. + + Resolution priority: + 1. ASPARA_DATA_DIR environment variable (if set) + 2. XDG_DATA_HOME/aspara (if XDG_DATA_HOME is set) + 3. ~/.local/share/aspara (fallback) + + Returns: + Path object pointing to the data directory. + + Raises: + ValueError: If ASPARA_DATA_DIR points to a system directory + + Examples: + >>> # Using ASPARA_DATA_DIR + >>> os.environ["ASPARA_DATA_DIR"] = "/custom/path" + >>> get_data_dir() + Path('/custom/path') + + >>> # Using XDG_DATA_HOME + >>> os.environ["XDG_DATA_HOME"] = "/home/user/.local/share" + >>> get_data_dir() + Path('/home/user/.local/share/aspara') + + >>> # Using fallback + >>> get_data_dir() + Path('/home/user/.local/share/aspara') + """ + # Priority 1: ASPARA_DATA_DIR environment variable + aspara_data_dir = os.environ.get("ASPARA_DATA_DIR") + if aspara_data_dir: + data_path = Path(aspara_data_dir).expanduser().resolve() + _validate_data_dir(data_path) + return data_path + + # Priority 2: XDG_DATA_HOME/aspara + xdg_data_home = os.environ.get("XDG_DATA_HOME") + if xdg_data_home: + return Path(xdg_data_home).expanduser() / "aspara" + + # Priority 3: ~/.local/share/aspara (fallback) + return Path.home() / ".local" / "share" / "aspara" + + +def get_project_search_mode() -> str: + """Get project search mode from environment variable. + + Returns: + Project search mode ("realtime" or "manual"). Defaults to "realtime". + """ + mode = os.environ.get("ASPARA_PROJECT_SEARCH_MODE", "realtime") + if mode not in ("realtime", "manual"): + return "realtime" + return mode + + +def get_storage_backend() -> str | None: + """Get storage backend from environment variable. + + Returns: + Storage backend name if ASPARA_STORAGE_BACKEND is set, None otherwise. + """ + return os.environ.get("ASPARA_STORAGE_BACKEND") + + +def use_lttb_fast() -> bool: + """Check if fast LTTB implementation should be used. + + Returns: + True if ASPARA_LTTB_FAST is set to "1", False otherwise. + """ + return os.environ.get("ASPARA_LTTB_FAST") == "1" + + +def is_dev_mode() -> bool: + """Check if running in development mode. + + Returns: + True if ASPARA_DEV_MODE is set to "1", False otherwise. + """ + return os.environ.get("ASPARA_DEV_MODE") == "1" + + +def is_read_only() -> bool: + """Check if running in read-only mode. + + Returns: + True if ASPARA_READ_ONLY is set to "1", False otherwise. + """ + return os.environ.get("ASPARA_READ_ONLY") == "1" + + +# Default SSE heartbeat interval in seconds. +# Sent to keep connections alive and detect dead clients. +_SSE_DEFAULT_HEARTBEAT_INTERVAL = 15 + +# Default SSE send timeout in seconds. +# If a client stops reading, the server gives up after this many seconds. +_SSE_DEFAULT_SEND_TIMEOUT = 30.0 + +# Timeout for closing the metrics async iterator during SSE cleanup. +# This is the upper bound for watcher unsubscribe / generator finalization +# when an SSE connection ends (either client disconnect or server shutdown). +SSE_METRICS_ITERATOR_CLOSE_TIMEOUT = 1.0 + +# Timeout for forcefully cancelling active SSE tasks during dev-mode shutdown. +# Must be >= SSE_METRICS_ITERATOR_CLOSE_TIMEOUT so that each cancelled task +# has enough time to run its `finally` block (which closes the metrics +# iterator) before the shutdown gives up. Production shutdown uses a fixed +# graceful drain instead (see dashboard.main.lifespan). +_SSE_DEV_SHUTDOWN_TIMEOUT = 2.0 + + +def get_sse_heartbeat_interval() -> int: + """Get the SSE heartbeat (ping) interval in seconds. + + Environment variable: + ASPARA_SSE_HEARTBEAT_INTERVAL: override the ping interval (default: 15) + """ + return int(os.environ.get("ASPARA_SSE_HEARTBEAT_INTERVAL", _SSE_DEFAULT_HEARTBEAT_INTERVAL)) + + +def get_sse_send_timeout() -> float: + """Get the SSE send timeout in seconds. + + Environment variable: + ASPARA_SSE_SEND_TIMEOUT: override the send timeout (default: 30.0) + """ + return float(os.environ.get("ASPARA_SSE_SEND_TIMEOUT", _SSE_DEFAULT_SEND_TIMEOUT)) + + +def get_sse_dev_shutdown_timeout() -> float: + """Get the SSE task cancellation timeout (seconds) used during dev-mode shutdown. + + Environment variable: + ASPARA_SSE_DEV_SHUTDOWN_TIMEOUT: override the timeout (default: 2.0) + """ + return float(os.environ.get("ASPARA_SSE_DEV_SHUTDOWN_TIMEOUT", _SSE_DEV_SHUTDOWN_TIMEOUT)) diff --git a/src/aspara/dashboard/__init__.py b/src/aspara/dashboard/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2c0bd38db4c1997cc2a2b0192fe1ec511ef5426a --- /dev/null +++ b/src/aspara/dashboard/__init__.py @@ -0,0 +1,5 @@ +""" +Aspara metrics visualization dashboard package! +""" + +__version__ = "0.1.0" diff --git a/src/aspara/dashboard/dependencies.py b/src/aspara/dashboard/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..a1820a8b578fe136f27e5a5126fca35f6e6179eb --- /dev/null +++ b/src/aspara/dashboard/dependencies.py @@ -0,0 +1,120 @@ +""" +FastAPI dependency injection for Aspara Dashboard. + +This module provides reusable dependencies for: +- Catalog instance management (ProjectCatalog, RunCatalog) +- Path parameter validation (project names, run names) +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +from typing import Annotated + +from fastapi import Depends, HTTPException +from fastapi import Path as PathParam + +from aspara.catalog import ProjectCatalog, RunCatalog +from aspara.config import get_data_dir +from aspara.utils import validators + +# Mutable container for custom data directory configuration +_custom_data_dir: list[str | None] = [None] + + +def _get_catalogs() -> tuple[ProjectCatalog, RunCatalog, Path]: + """Get or create catalog instances. + + Returns: + Tuple of (ProjectCatalog, RunCatalog, data_dir Path) + """ + if _custom_data_dir[0] is not None: + data_dir = Path(_custom_data_dir[0]) + else: + data_dir = Path(get_data_dir()) + return ProjectCatalog(str(data_dir)), RunCatalog(str(data_dir)), data_dir + + +# Cached version for performance +@lru_cache(maxsize=1) +def _get_cached_catalogs() -> tuple[ProjectCatalog, RunCatalog, Path]: + """Get cached catalog instances.""" + return _get_catalogs() + + +def get_project_catalog() -> ProjectCatalog: + """Get the ProjectCatalog singleton instance.""" + return _get_cached_catalogs()[0] + + +def get_run_catalog() -> RunCatalog: + """Get the RunCatalog singleton instance.""" + return _get_cached_catalogs()[1] + + +def get_data_dir_path() -> Path: + """Get the data directory path.""" + return _get_cached_catalogs()[2] + + +def configure_data_dir(data_dir: str | None = None) -> None: + """Configure data directory and reinitialize catalogs. + + This function clears the cached catalogs and reinitializes them + with the specified data directory. + + Args: + data_dir: Custom data directory path. If None, uses default. + """ + # Clear the cache to force reinitialization + _get_cached_catalogs.cache_clear() + + # Set custom data directory + _custom_data_dir[0] = data_dir + + +def get_validated_project(project: Annotated[str, PathParam(description="Project name")]) -> str: + """Validate project name path parameter. + + Args: + project: Project name from URL path. + + Returns: + Validated project name. + + Raises: + HTTPException: 400 if project name is invalid. + """ + try: + validators.validate_project_name(project) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + return project + + +def get_validated_run(run: Annotated[str, PathParam(description="Run name")]) -> str: + """Validate run name path parameter. + + Args: + run: Run name from URL path. + + Returns: + Validated run name. + + Raises: + HTTPException: 400 if run name is invalid. + """ + try: + validators.validate_run_name(run) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + return run + + +# Type aliases for dependency injection +ValidatedProject = Annotated[str, Depends(get_validated_project)] +ValidatedRun = Annotated[str, Depends(get_validated_run)] +ProjectCatalogDep = Annotated[ProjectCatalog, Depends(get_project_catalog)] +RunCatalogDep = Annotated[RunCatalog, Depends(get_run_catalog)] +DataDirDep = Annotated[Path, Depends(get_data_dir_path)] diff --git a/src/aspara/dashboard/main.py b/src/aspara/dashboard/main.py new file mode 100644 index 0000000000000000000000000000000000000000..5aa7303ec2a8d9c8facb0bbac26ceb964138afdd --- /dev/null +++ b/src/aspara/dashboard/main.py @@ -0,0 +1,157 @@ +""" +FastAPI application for Aspara Dashboard +""" + +import asyncio +import contextlib +import logging +import os +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response + +from aspara.catalog import DataDirWatcher +from aspara.config import get_sse_dev_shutdown_timeout, is_dev_mode + +from .router import router + +logger = logging.getLogger(__name__) + + +# Global state for SSE connection management +class AppState: + """Application state for managing SSE connections during shutdown.""" + + def __init__(self) -> None: + self.active_sse_connections: set[asyncio.Queue] = set() + self.active_sse_tasks: set[asyncio.Task] = set() + self.shutting_down = False + + +app_state = AppState() + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Middleware to add security headers to all responses.""" + + async def dispatch(self, request: Request, call_next) -> Response: + response = await call_next(request) + + # Prevent MIME type sniffing + response.headers["X-Content-Type-Options"] = "nosniff" + + # Prevent clickjacking by denying framing + response.headers["X-Frame-Options"] = "DENY" + + # Enable XSS filter in browsers (legacy but still useful) + response.headers["X-XSS-Protection"] = "1; mode=block" + + # HSTS - set unconditionally because: + # - Browsers ignore it on HTTP responses, so HTTP deployments are unaffected + # - Browsers ignore it from localhost (Chrome 132+, Firefox, Brave), + # so local development is never locked out + # - It only takes effect on HTTPS responses from non-localhost hosts, + # which is exactly the production case (HF Spaces, internal LAN TLS) + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + + # Content Security Policy - basic policy + # Allows self-origin scripts/styles, inline styles for chart libraries, + # and data: URIs for images (used by chart exports) + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "font-src 'self'; " + "connect-src 'self'; " + "frame-ancestors 'none'" + ) + + # Allow iframe embedding when ASPARA_ALLOW_IFRAME=1 (e.g., HF Spaces) + if os.environ.get("ASPARA_ALLOW_IFRAME") == "1": + del response.headers["X-Frame-Options"] + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "img-src 'self' data:; " + "font-src 'self' https://fonts.gstatic.com; " + "connect-src 'self'; " + "frame-ancestors https://huggingface.co https://*.hf.space" + ) + + return response + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Manage application lifecycle. + + On shutdown, signal all active SSE connections to close gracefully. + In development mode, forcefully cancel SSE tasks for fast restart. + """ + # Startup + yield + + # Shutdown + app_state.shutting_down = True + + # Signal all active SSE connections to stop + for queue in list(app_state.active_sse_connections): + # Queue might already be closed or event loop shutting down + with contextlib.suppress(RuntimeError, OSError): + await queue.put(None) # Sentinel value to signal shutdown + + if is_dev_mode(): + # Development mode: forcefully cancel SSE tasks for fast restart. + # The timeout must be >= SSE_METRICS_ITERATOR_CLOSE_TIMEOUT so each + # cancelled task can finish its `finally` cleanup (closing the + # metrics iterator / watcher unsubscribe) before we give up. + shutdown_timeout = get_sse_dev_shutdown_timeout() + logger.info(f"[DEV MODE] Cancelling {len(app_state.active_sse_tasks)} active SSE tasks (timeout={shutdown_timeout}s)") + for task in list(app_state.active_sse_tasks): + task.cancel() + + # Wait for tasks to be cancelled + if app_state.active_sse_tasks: + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for( + asyncio.gather(*app_state.active_sse_tasks, return_exceptions=True), + timeout=shutdown_timeout, + ) + logger.info("[DEV MODE] SSE tasks cancelled, shutdown complete") + else: + # Production mode: graceful shutdown with 30 second timeout + await asyncio.sleep(0.5) + + # Tear down the DataDirWatcher singleton so that the underlying + # awatch/inotify FD is closed and a subsequent reload (e.g. --dev + # auto-reload) does not reuse a stale watcher — which would leak + # inotify FDs and deliver duplicate events. + await DataDirWatcher.shutdown() + + +app = FastAPI( + title="Aspara Dashboard", + description="Real-time metrics visualization for machine learning experiments", + docs_url="/docs/dashboard" if is_dev_mode() else None, + redoc_url=None, + lifespan=lifespan, +) + +# Security headers middleware +app.add_middleware(SecurityHeadersMiddleware) # ty: ignore[invalid-argument-type] + +# No CORS middleware is configured intentionally. The dashboard static JS and API +# are served from the same origin, so CORS is unnecessary. Keeping a wildcard +# CORS policy would allow cross-origin sites to pass the X-Requested-With CSRF +# header check via preflight, defeating that protection. + +BASE_DIR = Path(__file__).parent +app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") + +app.include_router(router) diff --git a/src/aspara/dashboard/models/__init__.py b/src/aspara/dashboard/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d2ae415c0f982860d7b7499db6eec7ddc2a04159 --- /dev/null +++ b/src/aspara/dashboard/models/__init__.py @@ -0,0 +1,3 @@ +""" +Aspara dashboard data model definitions! +""" diff --git a/src/aspara/dashboard/models/metrics.py b/src/aspara/dashboard/models/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..73923efa059a779db99e8e549804380ed6c4e82b --- /dev/null +++ b/src/aspara/dashboard/models/metrics.py @@ -0,0 +1,49 @@ +""" +Models for metrics data. + +This module defines the data models for the dashboard API. +Note: experiment concept has been removed - data structure is now project/run. +""" + +from datetime import datetime + +from pydantic import BaseModel + +from aspara.catalog.project_catalog import ProjectInfo +from aspara.catalog.run_catalog import RunInfo + +__all__ = [ + "Metadata", + "MetadataUpdateRequest", + "MetricSeries", + "ProjectInfo", + "RunInfo", +] + + +class Metadata(BaseModel): + """Metadata for projects and runs.""" + + notes: str = "" + tags: list[str] = [] + created_at: datetime | None = None + updated_at: datetime | None = None + + +class MetadataUpdateRequest(BaseModel): + """Request model for updating metadata.""" + + notes: str | None = None + tags: list[str] | None = None + + +class MetricSeries(BaseModel): + """A single metric time series with steps, values, and timestamps. + + Used in the metrics API response to represent one metric's data. + Arrays are delta-compressed where applicable. + """ + + steps: list[int | float] + values: list[int | float] + timestamps: list[int | float] diff --git a/src/aspara/dashboard/router.py b/src/aspara/dashboard/router.py new file mode 100644 index 0000000000000000000000000000000000000000..2d9ef5d4c3ca00dde4bf0fca35e2d40f42abf55a --- /dev/null +++ b/src/aspara/dashboard/router.py @@ -0,0 +1,25 @@ +""" +Aspara Dashboard APIRouter aggregation. + +This module aggregates all route handlers from sub-modules: +- html_routes: HTML page endpoints +- api_routes: REST API endpoints +- sse_routes: Server-Sent Events streaming endpoints + +Note: experiment concept has been removed - URL structure is now /projects/{project}/runs/{run} +""" + +from __future__ import annotations + +from fastapi import APIRouter + +# Re-export configure_data_dir for backwards compatibility +from .dependencies import configure_data_dir +from .routes import api_router, html_router, sse_router + +router = APIRouter() +router.include_router(html_router) +router.include_router(api_router) +router.include_router(sse_router) + +__all__ = ["router", "configure_data_dir"] diff --git a/src/aspara/dashboard/routes/__init__.py b/src/aspara/dashboard/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..66cf21a0977cc928b168efbbecb0eb9bdacbf9ae --- /dev/null +++ b/src/aspara/dashboard/routes/__init__.py @@ -0,0 +1,14 @@ +""" +Aspara Dashboard routes. + +This package contains route handlers organized by type: +- html_routes: HTML page endpoints +- api_routes: REST API endpoints +- sse_routes: Server-Sent Events streaming endpoints +""" + +from .api_routes import router as api_router +from .html_routes import router as html_router +from .sse_routes import router as sse_router + +__all__ = ["html_router", "api_router", "sse_router"] diff --git a/src/aspara/dashboard/routes/api_routes.py b/src/aspara/dashboard/routes/api_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..6418f701c03e6aee3438c6af40a631d872f7fd74 --- /dev/null +++ b/src/aspara/dashboard/routes/api_routes.py @@ -0,0 +1,458 @@ +""" +REST API routes for Aspara Dashboard. + +This module handles all REST API endpoints: +- Artifacts download API +- Bulk metrics API +- Project/Run metadata APIs +- Delete APIs +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import tempfile +import urllib.parse +import zipfile +from collections import defaultdict +from collections.abc import Iterator +from datetime import datetime, timezone +from typing import Any + +import msgpack +from fastapi import APIRouter, Depends, Header, HTTPException, Query +from fastapi.responses import JSONResponse, Response, StreamingResponse + +from aspara.config import get_resource_limits, is_read_only +from aspara.exceptions import ProjectNotFoundError, RunNotFoundError +from aspara.utils import validators + +from ..dependencies import ( + DataDirDep, + ProjectCatalogDep, + RunCatalogDep, + ValidatedProject, + ValidatedRun, +) +from ..models.metrics import Metadata, MetadataUpdateRequest +from ..utils import parse_and_validate_run_list +from ..utils.compression import compress_metrics + + +async def verify_csrf_header(x_requested_with: str | None = Header(None, alias="X-Requested-With")) -> None: + """CSRF protection via custom header check. + + Verifies that requests include the X-Requested-With header, which cannot be set + by cross-origin requests without CORS preflight. This prevents CSRF attacks. + + Args: + x_requested_with: The X-Requested-With header value + + Raises: + HTTPException: 403 if header is missing + """ + if x_requested_with is None: + raise HTTPException(status_code=403, detail="Missing X-Requested-With header") + + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Spool threshold: zips smaller than this stay in memory; larger ones +# roll over to a temp file on disk. 1 MiB keeps per-request memory +# bounded while avoiding disk I/O for the common small-artifact case. +_ZIP_SPOOL_MAX_BYTES = 1 << 20 # 1 MiB +_ZIP_STREAM_CHUNK_SIZE = 64 * 1024 # 64 KiB + + +def _stream_zip( + artifact_entries: list[tuple[str, str, int]], +) -> Iterator[bytes]: + """Build a ZIP on a SpooledTemporaryFile and yield it in chunks. + + The ZIP is written to a ``SpooledTemporaryFile`` (in-memory up to + ``_ZIP_SPOOL_MAX_BYTES``, then transparently rolled to a temp file on + disk). After the ZIP is finalised the file pointer is rewound and the + content is yielded in fixed-size chunks. The temp file is closed in + the ``finally`` block so it is cleaned up even on client disconnect. + + Args: + artifact_entries: List of (name, path, size) tuples for the + files to include in the ZIP. + + Yields: + Chunks of the completed ZIP file. + """ + with tempfile.SpooledTemporaryFile(max_size=_ZIP_SPOOL_MAX_BYTES, suffix=".zip") as buf: + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zip_file: + for filename, file_path, _ in artifact_entries: + zip_file.write(file_path, filename) + buf.seek(0) + while True: + chunk = buf.read(_ZIP_STREAM_CHUNK_SIZE) + if not chunk: + break + yield chunk + + +@router.get("/api/projects/{project}/runs/{run}/artifacts/download") +async def download_artifacts_zip( + project: ValidatedProject, + run: ValidatedRun, + data_dir: DataDirDep, +) -> StreamingResponse: + """Download all artifacts for a run as a ZIP file. + + Args: + project: Project name. + run: Run name. + + Returns: + StreamingResponse with ZIP file containing all artifacts. + Filename format: `{project}_{run}_artifacts_{timestamp}.zip` + + Raises: + HTTPException: 400 if project/run name is invalid or total size exceeds limit, + 404 if no artifacts found. + """ + # Get the artifacts directory path + artifacts_dir = data_dir / project / run / "artifacts" + + # Validate path to prevent path traversal + try: + validators.validate_safe_path(artifacts_dir, data_dir) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid artifacts directory path: {e}") from None + + artifacts_dir_str = str(artifacts_dir) + + if not os.path.exists(artifacts_dir_str): + raise HTTPException(status_code=404, detail="No artifacts found for this run") + + # Single-pass: collect file info using scandir (caches stat results). + # Use follow_symlinks=False so that symlinks in the artifacts directory + # are not followed — this prevents a local attacker from tricking the + # ZIP builder into bundling files outside data_dir. + artifact_entries: list[tuple[str, str, int]] = [] # (name, path, size) + total_size = 0 + + with os.scandir(artifacts_dir_str) as entries: + for entry in entries: + if entry.is_file(follow_symlinks=False): + size = entry.stat(follow_symlinks=False).st_size + artifact_entries.append((entry.name, entry.path, size)) + total_size += size + elif entry.is_symlink(): + logger.warning(f"Skipping symlink in artifacts directory: {entry.path}") + + if not artifact_entries: + raise HTTPException(status_code=404, detail="No artifact files found") + + # Check total size + limits = get_resource_limits() + if total_size > limits.max_zip_size: + raise HTTPException( + status_code=400, + detail=(f"Total artifacts size ({total_size} bytes) exceeds maximum ZIP size limit ({limits.max_zip_size} bytes)"), + ) + + # Generate filename with timestamp + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + zip_filename = f"{project}_{run}_artifacts_{timestamp}.zip" + + # Encode filename for Content-Disposition header to prevent header + # injection. Use RFC 5987 encoding for non-ASCII characters. + encoded_filename = urllib.parse.quote(zip_filename, safe="") + + # Build the ZIP using a SpooledTemporaryFile so that memory usage is + # bounded (small zips stay in memory up to the spool threshold; larger + # ones roll over to a temp file on disk). The generator then streams + # the file in fixed-size chunks, keeping per-request memory constant + # regardless of total ZIP size. The temp file is cleaned up in the + # generator's finally block. + return StreamingResponse( + _stream_zip(artifact_entries), + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}, + ) + + +@router.get("/api/projects/{project}/runs/metrics") +async def runs_metrics_api( + project: ValidatedProject, + run_catalog: RunCatalogDep, + runs: str, + format: str = "json", + since: int | None = Query( + default=None, + description="Filter metrics since this UNIX timestamp in milliseconds", + ), +) -> Response: + """Get metrics for multiple runs in a single request. + + Useful for comparing metrics across runs. Returns data in metric-first structure + where each metric contains data from all requested runs. + + Args: + project: Project name. + runs: Comma-separated list of run names (e.g., "run1,run2,run3"). + format: Response format - "json" (default) or "msgpack". + since: Optional filter to only return metrics with timestamp >= since (UNIX ms). + + Returns: + Response with structure: `{"project": str, "metrics": {metric: {run: {...}}}}` + - For "json" format: JSONResponse + - For "msgpack" format: Response with application/x-msgpack content type + + Raises: + HTTPException: 400 if project name is invalid, format is invalid, + or too many runs specified. + """ + # Validate format parameter + if format not in ("json", "msgpack"): + raise HTTPException( + status_code=400, + detail=f"Invalid format: {format}. Must be 'json' or 'msgpack'", + ) + + try: + run_list = parse_and_validate_run_list(runs) + except ValueError as e: + if format == "msgpack": + raise HTTPException(status_code=400, detail=str(e)) from None + return JSONResponse(content={"error": str(e)}, status_code=400) + + # Convert since (UNIX ms) to datetime if provided + # Create timezone-naive datetime (matches DataFrame storage) + since_dt = datetime.fromtimestamp(since / 1000, tz=timezone.utc).replace(tzinfo=None) if since is not None else None + + # Load and downsample metrics for all runs in parallel + async def load_and_downsample( + run_name: str, + ) -> tuple[str, dict[str, dict[str, list]] | None]: + """Load and downsample metrics for a single run.""" + try: + df = await asyncio.to_thread(run_catalog.load_metrics, project, run_name, since_dt) + return (run_name, compress_metrics(df)) + except Exception as e: + logger.warning(f"Failed to load metrics for {project}/{run_name}: {type(e).__name__}: {e}") + return (run_name, None) + + # Execute all loads in parallel + results = await asyncio.gather(*[load_and_downsample(run_name) for run_name in run_list]) + + # Build metrics_by_run from results + metrics_by_run: dict[str, dict[str, dict[str, list]]] = {} + for run_name, metrics in results: + if metrics is not None: + metrics_by_run[run_name] = metrics + + # Reorganize to metric-first structure using defaultdict for O(1) key insertion + metrics_data: dict[str, dict[str, dict[str, list]]] = defaultdict(dict) + for run_name, run_metrics in metrics_by_run.items(): + for metric_name, metric_arrays in run_metrics.items(): + metrics_data[metric_name][run_name] = metric_arrays + + response_data = {"project": project, "metrics": metrics_data} + + # Return response based on format + if format == "msgpack": + # Serialize to MessagePack + packed_data = msgpack.packb(response_data, use_single_float=True) + return Response(content=packed_data, media_type="application/x-msgpack") + + return JSONResponse(content=response_data) + + +@router.get("/api/projects/{project}/metadata") +async def get_project_metadata_api( + project: ValidatedProject, + project_catalog: ProjectCatalogDep, +) -> Metadata: + """Get project metadata. + + Args: + project: Project name. + + Returns: + Metadata object containing project metadata (tags, notes, etc.). + + Raises: + HTTPException: 400 if project name is invalid. + """ + # Use ProjectCatalog metadata API. + # Read-only catalog call: offload to a worker thread so the event loop + # is not blocked while waiting on file I/O. Write paths + # (update_metadata / delete) stay synchronous because they use a + # read-modify-write pattern that would race if run concurrently. + metadata = await asyncio.to_thread(project_catalog.get_metadata, project) + return Metadata.model_validate(metadata) + + +@router.put("/api/projects/{project}/metadata") +async def update_project_metadata_api( + project: ValidatedProject, + metadata: MetadataUpdateRequest, + project_catalog: ProjectCatalogDep, + _csrf: None = Depends(verify_csrf_header), +) -> Metadata: + """Update project metadata. + + Args: + project: Project name. + metadata: MetadataUpdateRequest containing fields to update. + + Returns: + Metadata object containing the updated project metadata. + + Raises: + HTTPException: 400 if project name is invalid. + """ + if is_read_only(): + existing = await asyncio.to_thread(project_catalog.get_metadata, project) + return Metadata.model_validate(existing) + + update_data = metadata.model_dump(exclude_none=True) + + # Use ProjectCatalog metadata API + updated_metadata = project_catalog.update_metadata(project, update_data) + return Metadata.model_validate(updated_metadata) + + +@router.delete("/api/projects/{project}") +async def delete_project( + project: ValidatedProject, + project_catalog: ProjectCatalogDep, + _csrf: None = Depends(verify_csrf_header), +) -> Response: + """Delete a project and all its runs. + + **Warning**: This operation is irreversible. + + Args: + project: Project name. + + Returns: + 204 No Content on success. + + Raises: + HTTPException: 400 if project name is invalid, 403 if permission denied, + 404 if project not found, 500 for unexpected errors. + """ + if is_read_only(): + return Response(status_code=204) + + try: + project_catalog.delete(project) + logger.info(f"Deleted project: {project}") + return Response(status_code=204) + except ProjectNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except PermissionError as e: + logger.warning(f"Permission denied deleting project {project}: {e}") + raise HTTPException(status_code=403, detail="Permission denied") from e + except Exception as e: + logger.error(f"Error deleting project {project}: {type(e).__name__}: {e}") + raise HTTPException(status_code=500, detail="Failed to delete project") from e + + +@router.get("/api/projects/{project}/runs/{run}/metadata") +async def get_run_metadata_api( + project: ValidatedProject, + run: ValidatedRun, + run_catalog: RunCatalogDep, +) -> dict[str, Any]: + """Get run metadata. + + Args: + project: Project name. + run: Run name. + + Returns: + Dictionary containing run metadata (tags, notes, params, etc.). + + Raises: + HTTPException: 400 if project/run name is invalid. + """ + # Read-only catalog call: offload to a worker thread so the event + # loop is not blocked on file I/O. Write paths stay synchronous to + # avoid read-modify-write races (see get_project_metadata_api for + # the rationale). + metadata = await asyncio.to_thread(run_catalog.get_metadata, project, run) + return metadata + + +@router.put("/api/projects/{project}/runs/{run}/metadata") +async def update_run_metadata_api( + project: ValidatedProject, + run: ValidatedRun, + metadata: MetadataUpdateRequest, + run_catalog: RunCatalogDep, + _csrf: None = Depends(verify_csrf_header), +) -> dict[str, Any]: + """Update run metadata. + + Args: + project: Project name. + run: Run name. + metadata: MetadataUpdateRequest containing fields to update. + + Returns: + Dictionary containing the updated run metadata. + + Raises: + HTTPException: 400 if project/run name is invalid. + """ + if is_read_only(): + existing = await asyncio.to_thread(run_catalog.get_metadata, project, run) + return existing + + update_data = metadata.model_dump(exclude_none=True) + + # Use RunCatalog metadata API + updated_metadata = run_catalog.update_metadata(project, run, update_data) + return updated_metadata + + +@router.delete("/api/projects/{project}/runs/{run}") +async def delete_run( + project: ValidatedProject, + run: ValidatedRun, + run_catalog: RunCatalogDep, + _csrf: None = Depends(verify_csrf_header), +) -> Response: + """Delete a run and its artifacts. + + **Warning**: This operation is irreversible. + + Args: + project: Project name. + run: Run name. + + Returns: + 204 No Content on success. + + Raises: + HTTPException: 400 if project/run name is invalid, 403 if permission denied, + 404 if project or run not found, 500 for unexpected errors. + """ + if is_read_only(): + return Response(status_code=204) + + try: + run_catalog.delete(project, run) + logger.info(f"Deleted run: {project}/{run}") + return Response(status_code=204) + except ProjectNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except RunNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except PermissionError as e: + logger.warning(f"Permission denied deleting run {project}/{run}: {e}") + raise HTTPException(status_code=403, detail="Permission denied") from e + except Exception as e: + logger.error(f"Error deleting run {project}/{run}: {type(e).__name__}: {e}") + raise HTTPException(status_code=500, detail="Failed to delete run") from e diff --git a/src/aspara/dashboard/routes/html_routes.py b/src/aspara/dashboard/routes/html_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..042d57f448c7404cbe30985b18ccece61ddcdad9 --- /dev/null +++ b/src/aspara/dashboard/routes/html_routes.py @@ -0,0 +1,327 @@ +""" +HTML page routes for Aspara Dashboard. + +This module handles all HTML page rendering endpoints: +- Home page (projects list) +- Project detail page +- Runs list page +- Run detail page +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, HTTPException +from fastapi.responses import HTMLResponse +from starlette.requests import Request + +from aspara.config import is_read_only +from aspara.exceptions import RunNotFoundError +from aspara.models import RunStatus +from aspara.utils.timestamp import parse_to_ms + +from ..dependencies import ( + ProjectCatalogDep, + RunCatalogDep, + ValidatedProject, + ValidatedRun, +) +from ..services.template_service import ( + TemplateService, + create_breadcrumbs, + render_mustache_response, +) + +router = APIRouter() + + +def _format_duration_ms(duration_ms: float | int | None) -> str: + """Format a duration given in milliseconds into a human-readable string. + + Returns ``"N/A"`` when no duration is available. + """ + if duration_ms is None or duration_ms < 0: + return "N/A" + seconds = duration_ms / 1000.0 + if seconds < 1: + return f"{int(duration_ms)}ms" + if seconds < 60: + return f"{seconds:.1f}s" + total_seconds = int(seconds) + minutes = total_seconds // 60 + secs = total_seconds % 60 + if minutes < 60: + return f"{minutes}m {secs}s" + hours = minutes // 60 + mins = minutes % 60 + if hours < 24: + return f"{hours}h {mins}m" + days = hours // 24 + hrs = hours % 24 + return f"{days}d {hrs}h" + + +@router.get("/") +async def home( + request: Request, + project_catalog: ProjectCatalogDep, +) -> HTMLResponse: + """Render the projects list page.""" + # Read-only catalog call: offload to worker threads so the event + # loop is not blocked on file I/O. Write paths stay synchronous to + # avoid read-modify-write races (see api_routes.get_project_metadata_api). + # Project metadata is loaded together with the directory scan in one pass + # to avoid the N+1 pattern of per-project file opens. + projects_with_metadata = await asyncio.to_thread(project_catalog.get_projects_with_metadata) + + formatted_projects = [] + for project, metadata in projects_with_metadata: + tags = metadata.get("tags") or [] + formatted_projects.append(TemplateService.format_project_for_template(project, tags)) + + from aspara.config import get_project_search_mode + + project_search_mode = get_project_search_mode() + + context = { + "page_title": "Aspara", + "breadcrumbs": create_breadcrumbs([{"label": "Home", "is_home": True}]), + "projects": formatted_projects, + "has_projects": len(formatted_projects) > 0, + "project_search_mode": project_search_mode, + "read_only": is_read_only(), + } + + html = render_mustache_response("projects_list", context) + return HTMLResponse(content=html) + + +@router.get("/projects/{project}") +async def project_detail( + request: Request, + project: ValidatedProject, + project_catalog: ProjectCatalogDep, + run_catalog: RunCatalogDep, +) -> HTMLResponse: + """Project detail page - shows metrics charts.""" + # Check if project exists + if not await asyncio.to_thread(project_catalog.exists, project): + raise HTTPException(status_code=404, detail=f"Project '{project}' not found") + + runs = await asyncio.to_thread(run_catalog.get_runs, project) + + # Format runs for template (excluding corrupted runs) + formatted_runs = [] + for run in runs: + formatted = TemplateService.format_run_for_project_detail(run) + if formatted is not None: + formatted_runs.append(formatted) + + # Find the most recent last_update from all runs + project_last_update = None + if runs: + last_updates = [r.last_update for r in runs if r.last_update is not None] + if last_updates: + project_last_update = max(last_updates) + + context = { + "page_title": f"{project} - Metrics", + "breadcrumbs": create_breadcrumbs([ + {"label": "Home", "url": "/", "is_home": True}, + {"label": project}, + ]), + "project": project, + "runs": formatted_runs, + "has_runs": len(formatted_runs) > 0, + "run_count": len(formatted_runs), + "formatted_project_last_update": (project_last_update.strftime("%b %d, %Y at %I:%M %p") if project_last_update else "N/A"), + "read_only": is_read_only(), + } + + html = render_mustache_response("project_detail", context) + return HTMLResponse(content=html) + + +@router.get("/projects/{project}/runs") +async def list_project_runs( + request: Request, + project: ValidatedProject, + project_catalog: ProjectCatalogDep, + run_catalog: RunCatalogDep, +) -> HTMLResponse: + """List runs in a project.""" + # Check if project exists + if not await asyncio.to_thread(project_catalog.exists, project): + raise HTTPException(status_code=404, detail=f"Project '{project}' not found") + + runs = await asyncio.to_thread(run_catalog.get_runs, project) + + # Format runs for template + formatted_runs = [TemplateService.format_run_for_list(run) for run in runs] + + context = { + "page_title": f"{project} - Runs", + "breadcrumbs": create_breadcrumbs([ + {"label": "Home", "url": "/", "is_home": True}, + {"label": project, "url": f"/projects/{project}"}, + {"label": "Runs"}, + ]), + "project": project, + "runs": formatted_runs, + "has_runs": len(formatted_runs) > 0, + "read_only": is_read_only(), + } + + html = render_mustache_response("runs_list", context) + return HTMLResponse(content=html) + + +@router.get("/projects/{project}/runs/{run}") +async def get_run( + request: Request, + project: ValidatedProject, + run: ValidatedRun, + project_catalog: ProjectCatalogDep, + run_catalog: RunCatalogDep, +) -> HTMLResponse: + """Get run details including parameters and metrics.""" + # Check if project exists + if not await asyncio.to_thread(project_catalog.exists, project): + raise HTTPException(status_code=404, detail=f"Project '{project}' not found") + + # Get Run information and check if it's corrupted + try: + current_run = await asyncio.to_thread(run_catalog.get, project, run) + except RunNotFoundError as e: + raise HTTPException(status_code=404, detail=f"Run '{run}' not found in project '{project}'") from e + + is_corrupted = current_run.is_corrupted + error_message = current_run.error_message + run_tags = current_run.tags + + # Load metrics, artifacts, and metadata in parallel + df_metrics, artifacts, metadata = await asyncio.gather( + asyncio.to_thread(run_catalog.load_metrics, project, run), + run_catalog.get_artifacts_async(project, run), + run_catalog.get_run_config_async(project, run), + ) + + # Extract params from metadata + params: dict[str, Any] = {} + params.update(metadata.get("params", {})) + params.update(metadata.get("config", {})) + + # Format data for template + formatted_params = [{"key": k, "value": v} for k, v in params.items()] + + # Get latest metrics for scalar display from wide-format DataFrame + latest_metrics: dict[str, Any] = {} + if len(df_metrics) > 0: + # Get last row (latest metrics) + last_row = df_metrics.tail(1).to_dicts()[0] + # Extract metric columns (those starting with underscore) + for col, value in last_row.items(): + if col.startswith("_") and value is not None: + # Remove underscore prefix + metric_name = col[1:] + latest_metrics[metric_name] = value + + formatted_latest_metrics = [{"key": k, "value": f"{v:.4f}" if isinstance(v, int | float) else str(v)} for k, v in latest_metrics.items()] + + # Resolve start/finish timestamps (in ms) from metadata. The metadata may + # store them as either UNIX milliseconds (real API) or ISO 8601 strings + # (legacy/test fixtures), so normalize via parse_to_ms. + start_time_raw = metadata.get("start_time") + finish_time_raw = metadata.get("finish_time") + start_time_ms: int | None = None + finish_time_ms: int | None = None + if start_time_raw is not None: + try: + start_time_ms = parse_to_ms(start_time_raw) + except ValueError: + start_time_ms = None + if finish_time_raw is not None: + try: + finish_time_ms = parse_to_ms(finish_time_raw) + except ValueError: + finish_time_ms = None + + # Compute duration. For WIP runs (no finish_time), use the most recent + # metrics timestamp as the current end so the user sees elapsed time. + duration_ms: int | None = None + if start_time_ms is not None: + end_ms: int | None = finish_time_ms + if end_ms is None and len(df_metrics) > 0 and "timestamp" in df_metrics.columns: + last_ts = df_metrics.select("timestamp").to_series().max() + if isinstance(last_ts, datetime): + end_ms = int(last_ts.timestamp() * 1000) + elif isinstance(last_ts, (int, float)): + end_ms = int(last_ts) + if end_ms is not None: + duration_ms = end_ms - start_time_ms + + if duration_ms is not None: + formatted_duration = _format_duration_ms(duration_ms) + elif not is_corrupted and current_run.status == RunStatus.WIP: + # WIP run with no metrics yet. + formatted_duration = "Running..." + else: + formatted_duration = "N/A" + + # Step count = number of logged metric rows + step_count = len(df_metrics) + + # Start time display: prefer metadata start_time, fall back to DataFrame + start_time_display = "N/A" + if start_time_ms is not None: + try: + from aspara.utils.timestamp import parse_to_datetime + + start_time_display = parse_to_datetime(start_time_ms).strftime("%B %d, %Y at %I:%M %p") + except ValueError: + start_time_display = "N/A" + elif len(df_metrics) > 0 and "timestamp" in df_metrics.columns: + ts = df_metrics.select("timestamp").to_series().min() + if isinstance(ts, datetime): + start_time_display = ts.strftime("%B %d, %Y at %I:%M %p") + + # Run status flags for template rendering + status = current_run.status + status_value = status.value + + context = { + "page_title": f"{run} - Details", + "breadcrumbs": create_breadcrumbs([ + {"label": "Home", "url": "/", "is_home": True}, + {"label": project, "url": f"/projects/{project}"}, + {"label": "Runs", "url": f"/projects/{project}/runs"}, + {"label": run}, + ]), + "project": project, + "run_name": run, + "params": formatted_params, + "has_params": len(formatted_params) > 0, + "latest_metrics": formatted_latest_metrics, + "has_latest_metrics": len(formatted_latest_metrics) > 0, + "formatted_start_time": start_time_display, + "duration": formatted_duration, + "step_count": step_count, + "status": status_value, + "is_wip": status == RunStatus.WIP, + "is_completed": status == RunStatus.COMPLETED, + "is_failed": status == RunStatus.FAILED, + "is_maybe_failed": status == RunStatus.MAYBE_FAILED, + "has_tags": len(run_tags) > 0, + "tags": run_tags, + "artifacts": [TemplateService.format_artifact_for_template(artifact) for artifact in artifacts], + "has_artifacts": len(artifacts) > 0, + "is_corrupted": is_corrupted, + "error_message": error_message, + "read_only": is_read_only(), + } + + html = render_mustache_response("run_detail", context) + return HTMLResponse(content=html) diff --git a/src/aspara/dashboard/routes/sse_routes.py b/src/aspara/dashboard/routes/sse_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..28addbd19f901809fc6a399f4be8636ffca65fb2 --- /dev/null +++ b/src/aspara/dashboard/routes/sse_routes.py @@ -0,0 +1,226 @@ +""" +Server-Sent Events (SSE) routes for Aspara Dashboard. + +This module handles real-time streaming endpoints: +- Multiple runs metrics streaming +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Coroutine +from contextlib import suppress +from datetime import datetime, timezone +from typing import Any, cast + +from fastapi import APIRouter, Query +from sse_starlette.sse import EventSourceResponse + +from aspara.config import ( + SSE_METRICS_ITERATOR_CLOSE_TIMEOUT, + get_sse_heartbeat_interval, + get_sse_send_timeout, + is_dev_mode, +) +from aspara.models import MetricRecord, StatusRecord + +from ..dependencies import RunCatalogDep, ValidatedProject +from ..utils import parse_and_validate_run_list + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/api/projects/{project}/runs/stream") +async def stream_multiple_runs( + project: ValidatedProject, + run_catalog: RunCatalogDep, + runs: str, + since: int = Query( + ..., + description="Filter metrics since this UNIX timestamp in milliseconds", + ), +) -> EventSourceResponse: + """Stream metrics for multiple runs using Server-Sent Events (SSE). + + Args: + project: Project name. + runs: Comma-separated list of run names (e.g., "run1,run2,run3"). + since: Filter to only stream metrics with timestamp >= since (required, UNIX ms). + + Returns: + EventSourceResponse streaming metric and status events from all specified runs. + Event types: + - `metric`: `{"event": "metric", "data": }` + - `status`: `{"event": "status", "data": }` + + Raises: + HTTPException: 400 if project/run name is invalid, 422 if since is missing. + """ + logger.info(f"[SSE ENDPOINT] Called with project={project}, runs={runs}") + + from ..main import app_state + + try: + run_list = parse_and_validate_run_list(runs) + except ValueError as e: + + async def validation_error_generator(msg: str = str(e)): + yield {"event": "error", "data": msg} + + return EventSourceResponse(validation_error_generator()) + + # Convert UNIX ms to datetime + since_dt = datetime.fromtimestamp(since / 1000, tz=timezone.utc) + + async def event_generator(): + logger.info(f"[SSE] event_generator started for project={project}, runs={run_list}") + + # Register current task for dev mode forced cancellation + current_task = asyncio.current_task() + if current_task is not None: + app_state.active_sse_tasks.add(current_task) + + # Create shutdown queue for this connection + shutdown_queue: asyncio.Queue[None] = asyncio.Queue() + app_state.active_sse_connections.add(shutdown_queue) + + # Use new subscribe() method with singleton watcher + targets = {project: run_list} + metrics_iterator = run_catalog.subscribe(targets, since=since_dt).__aiter__() + logger.info("[SSE] Created metrics_iterator using subscribe()") + + # In dev mode, use shorter timeout for faster shutdown detection + dev_mode = is_dev_mode() + wait_timeout = 1.0 if dev_mode else None + + # Track pending metric task to avoid re-creating it after timeout + # IMPORTANT: Cancelling a task that's awaiting inside an async generator + # will close the generator. We must NOT cancel metric_task on timeout. + pending_metric_task: asyncio.Task[MetricRecord | StatusRecord] | None = None + + try: + while True: + # Check shutdown flag in dev mode + if dev_mode and app_state.shutting_down: + logger.info("[SSE] Dev mode: shutdown flag detected") + # Cancel pending metric_task before exiting + if pending_metric_task is not None: + pending_metric_task.cancel() + with suppress(asyncio.CancelledError): + await pending_metric_task + break + + # Create metric_task only if we don't have a pending one + if pending_metric_task is None: + metric_coro = cast( + "Coroutine[Any, Any, MetricRecord | StatusRecord]", + metrics_iterator.__anext__(), + ) + pending_metric_task = asyncio.create_task(metric_coro, name="metric_task") + + # Always create a new shutdown_task + shutdown_coro = cast("Coroutine[Any, Any, Any]", shutdown_queue.get()) + shutdown_task = asyncio.create_task(shutdown_coro, name="shutdown_task") + + try: + done, pending = await asyncio.wait( + [pending_metric_task, shutdown_task], + return_when=asyncio.FIRST_COMPLETED, + timeout=wait_timeout, + ) + except asyncio.CancelledError: + # Cancelled by lifespan handler in dev mode + logger.info("[SSE] Task cancelled (dev mode shutdown)") + pending_metric_task.cancel() + shutdown_task.cancel() + with suppress(asyncio.CancelledError): + await pending_metric_task + with suppress(asyncio.CancelledError): + await shutdown_task + raise + + # Handle timeout (dev mode only) + if not done: + # Timeout occurred - only cancel shutdown_task, NOT metric_task + # Cancelling metric_task would close the async generator! + shutdown_task.cancel() + with suppress(asyncio.CancelledError): + await shutdown_task + # pending_metric_task is kept and will be reused in next iteration + continue + + logger.debug(f"[SSE] asyncio.wait returned: done={[t.get_name() for t in done]}, pending={[t.get_name() for t in pending]}") + + # Cancel pending tasks (but NOT metric_task if it's pending) + if shutdown_task in pending: + shutdown_task.cancel() + with suppress(asyncio.CancelledError): + await shutdown_task + + # Check which task completed + if pending_metric_task in done: + # Reset so we create a new task in next iteration + completed_task = pending_metric_task + pending_metric_task = None + try: + record = completed_task.result() + if isinstance(record, MetricRecord): + logger.debug(f"[SSE] Sending metric to client: run={record.run}, step={record.step}") + yield {"event": "metric", "data": record.model_dump_json()} + elif isinstance(record, StatusRecord): + logger.info(f"[SSE] Sending status update to client: run={record.run}, status={record.status}") + yield {"event": "status", "data": record.model_dump_json()} + except StopAsyncIteration: + logger.info("[SSE] No more records (StopAsyncIteration)") + break + elif shutdown_task in done: + logger.info("[SSE] Shutdown requested") + # Cancel metric_task since we're shutting down + if pending_metric_task is not None: + pending_metric_task.cancel() + with suppress(asyncio.CancelledError): + await pending_metric_task + break + + except asyncio.CancelledError: + logger.info("[SSE] Generator cancelled") + raise + except Exception as e: + # Log the full exception internally. In production, send a generic + # message to the client so internal paths, library internals, or + # other sensitive details are not leaked over the wire. In dev + # mode, include the exception text for easier debugging. + logger.error(f"[SSE] Exception in event_generator: {e}", exc_info=True) + error_data = str(e) if dev_mode else "Internal server error" + yield {"event": "error", "data": error_data} + finally: + # Clean up: remove this connection from active set + logger.info("[SSE] event_generator finished, cleaning up") + app_state.active_sse_connections.discard(shutdown_queue) + if current_task is not None: + app_state.active_sse_tasks.discard(current_task) + # Cancel pending metric task if still running + if pending_metric_task is not None and not pending_metric_task.done(): + pending_metric_task.cancel() + with suppress(asyncio.CancelledError): + await pending_metric_task + # Close the async generator to trigger watcher unsubscribe + try: + await asyncio.wait_for( + metrics_iterator.aclose(), + timeout=SSE_METRICS_ITERATOR_CLOSE_TIMEOUT, + ) + except asyncio.TimeoutError: + logger.warning("[SSE] Timeout closing metrics_iterator") + except Exception as e: + logger.warning(f"[SSE] Error closing metrics_iterator: {e}") + + logger.info(f"[SSE ENDPOINT] Returning EventSourceResponse for runs={run_list}") + return EventSourceResponse( + event_generator(), + ping=get_sse_heartbeat_interval(), + send_timeout=get_sse_send_timeout(), + ) diff --git a/src/aspara/dashboard/services/__init__.py b/src/aspara/dashboard/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a0b2b299673a21463518e6eb1f4d8cf63f0dd874 --- /dev/null +++ b/src/aspara/dashboard/services/__init__.py @@ -0,0 +1,9 @@ +""" +Aspara Dashboard services. + +This package contains business logic services for the dashboard. +""" + +from .template_service import TemplateService, create_breadcrumbs, render_mustache_response + +__all__ = ["TemplateService", "create_breadcrumbs", "render_mustache_response"] diff --git a/src/aspara/dashboard/services/template_service.py b/src/aspara/dashboard/services/template_service.py new file mode 100644 index 0000000000000000000000000000000000000000..bee309ffcb82af06ae932c67870959034c5717b0 --- /dev/null +++ b/src/aspara/dashboard/services/template_service.py @@ -0,0 +1,167 @@ +""" +Template rendering service for Aspara Dashboard. + +Provides Mustache template rendering and context formatting utilities. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import pystache + +from aspara.catalog import ProjectInfo, RunInfo +from aspara.config import get_resource_limits, is_dev_mode + +BASE_DIR = Path(__file__).parent.parent +_mustache_renderer = pystache.Renderer(search_dirs=[str(BASE_DIR / "templates")]) + + +def create_breadcrumbs(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Create standardized breadcrumbs with consistent formatting. + + Args: + items: List of breadcrumb items with 'label' and optional 'url' keys. + First item is assumed to be Home. + + Returns: + List of breadcrumb items with consistent is_not_first flags. + """ + result = [] + + for i, item in enumerate(items): + crumb = item.copy() + crumb["is_not_first"] = i != 0 + + # Add home icon to first item if not already specified + if i == 0 and "is_home" not in crumb: + crumb["is_home"] = True + + result.append(crumb) + + return result + + +def render_mustache_response(template_name: str, context: dict[str, Any]) -> str: + """Render mustache template with context. + + Args: + template_name: Name of the template file (without extension). + context: Template context dictionary. + + Returns: + Rendered HTML string. + """ + # Add common context variables + context.update({ + "current_year": datetime.now(timezone.utc).year, + "page_title": context.get("page_title", "Aspara"), + "dev_mode": is_dev_mode(), + # Expose server-side resource limits to the client so the server + # remains the single source of truth for validation bounds. + "max_notes_length": get_resource_limits().max_notes_length, + }) + + # Render content template + content = _mustache_renderer.render_name(template_name, context) + + # Render layout with content + layout_context = context.copy() + layout_context["content"] = content + + return _mustache_renderer.render_name("layout", layout_context) + + +class TemplateService: + """Service for template rendering and data formatting. + + This class provides methods for formatting data objects for template rendering. + """ + + @staticmethod + def format_project_for_template(project: ProjectInfo, tags: list[str] | None = None) -> dict[str, Any]: + """Format a ProjectInfo for template rendering. + + Args: + project: ProjectInfo object. + tags: Optional list of tags from metadata. + + Returns: + Dictionary suitable for template rendering. + """ + return { + "name": project.name, + "run_count": project.run_count or 0, + "last_update": int(project.last_update.timestamp() * 1000) if project.last_update else 0, + "formatted_last_update": (project.last_update.strftime("%B %d, %Y at %I:%M %p") if project.last_update else "N/A"), + "tags": tags or [], + } + + @staticmethod + def format_run_for_list(run: RunInfo) -> dict[str, Any]: + """Format a RunInfo for runs list template. + + Args: + run: RunInfo object. + + Returns: + Dictionary suitable for runs list template rendering. + """ + return { + "name": run.name, + "param_count": run.param_count or 0, + "last_update": int(run.last_update.timestamp() * 1000) if run.last_update else 0, + "formatted_last_update": (run.last_update.strftime("%B %d, %Y at %I:%M %p") if run.last_update else "N/A"), + "is_corrupted": run.is_corrupted, + "error_message": run.error_message, + "tags": run.tags, + "has_tags": len(run.tags) > 0, + "is_finished": run.is_finished, + "is_wip": run.status.value == "wip", + "status": run.status.value, + } + + @staticmethod + def format_run_for_project_detail(run: RunInfo) -> dict[str, Any] | None: + """Format a RunInfo for project detail template (excludes corrupted runs). + + Args: + run: RunInfo object. + + Returns: + Dictionary suitable for project detail template rendering, + or None if the run is corrupted. + """ + if run.is_corrupted: + return None + + return { + "name": run.name, + "last_update": int(run.last_update.timestamp() * 1000) if run.last_update else 0, + "formatted_last_update": (run.last_update.strftime("%B %d, %Y at %I:%M %p") if run.last_update else "N/A"), + "is_finished": run.is_finished, + "is_wip": run.status.value == "wip", + "status": run.status.value, + } + + @staticmethod + def format_artifact_for_template(artifact: dict[str, Any]) -> dict[str, Any]: + """Format an artifact for template rendering with category flags. + + Args: + artifact: Artifact dictionary. + + Returns: + Dictionary with category boolean flags added. + """ + category = artifact.get("category") + return { + **artifact, + "is_code": category == "code", + "is_config": category == "config", + "is_model": category == "model", + "is_data": category == "data", + "is_other": category == "other" or category is None, + } diff --git a/src/aspara/dashboard/static/css/input.css b/src/aspara/dashboard/static/css/input.css new file mode 100644 index 0000000000000000000000000000000000000000..ff86a3f54a74d94585b0e2d1b0f98816fe29023d --- /dev/null +++ b/src/aspara/dashboard/static/css/input.css @@ -0,0 +1,217 @@ +@import "tailwindcss"; +@source "../../templates/**/*.mustache"; + +/* === Global reduced-motion guard === + * Disable all CSS animations and transitions when the user has + * requested reduced motion at the OS / browser level. This is the + * single source of truth for reduced-motion handling — component- + * specific @media blocks are no longer needed. + * Order matters: this must come after @import "tailwindcss" so it + * can override Tailwind's animate-* utilities (e.g. animate-pulse). + */ +@media (prefers-reduced-motion: reduce) { + *, + ::before, + ::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +@theme { + --color-action: #2C2520; + --color-action-hover: #1a1512; + --color-action-disabled: #d4cfc9; + + --color-secondary: #8B7F75; + --color-secondary-hover: #6B5F55; + + --color-accent: #CC785C; + --color-accent-hover: #B5654A; + --color-accent-light: #E8A892; + + --color-base-bg: #F5F3F0; + --color-base-border: #E6E3E0; + --color-base-surface: #FDFCFB; + + --color-text-primary: #2C2520; + --color-text-secondary: #6B5F55; + --color-text-muted: #9B8F85; + + --color-status-error: #C84C3C; + --color-status-success: #5A8B6F; + --color-status-warning: #D4864E; + + --font-family-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-family-mono: "JetBrains Mono", Consolas, Monaco, monospace; + + --radius-button: 0.5rem; +} + +/* Custom styles beyond Tailwind */ +.plot-container { + height: 24rem; + background: #FDFCFB; + padding: 1rem; + border-radius: 0; + border: 1px solid #E6E3E0; + box-shadow: none; +} + +.sidebar { + width: 16rem; + background: #FDFCFB; + border: 1px solid #E6E3E0; + box-shadow: none; +} + +/* Sidebar animation - optimized for responsiveness */ +#runs-sidebar { + transition: width 250ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.content-area { + padding: 2rem; + flex: 1; +} + +/* === @jcubic/tagger Aspara Theme Override === */ + +/* Container - border and background only */ +.tagger { + border: 1px solid var(--color-base-border); + border-radius: 0.375rem; + background: var(--color-base-surface); +} + +/* Tags - color and border-radius only, keep original padding/display */ +.tagger > ul > li:not(.tagger-new) > :first-child { + background: var(--color-base-bg); + border: 1px solid var(--color-base-border); + border-radius: 9999px; + /* padding is kept as original 4px 4px 4px 8px - do not override */ +} + +/* Tag text color */ +.tagger > ul > li:not(.tagger-new) span.label { + color: var(--color-text-muted); +} + +/* Close button */ +.tagger li a.close { + color: var(--color-text-muted); +} +.tagger li a.close:hover { + color: var(--color-text-primary); +} + +/* Input field */ +.tagger .tagger-new input { + font-size: 0.75rem; + color: var(--color-text-primary); +} +.tagger .tagger-new input::placeholder { + color: var(--color-text-muted); +} + +/* === Status Icon Styles (based on data-status attribute) === */ + +[data-status="wip"] { + @apply animate-pulse text-status-warning; +} + +[data-status="completed"] { + @apply text-status-success; +} + +[data-status="failed"] { + @apply text-status-error; +} + +[data-status="maybe_failed"] { + @apply text-status-warning; +} + +/* === Note Editor Cursor Styles === */ + +/* Placeholder (Add note...) cursor */ +.note-content .text-text-muted.italic { + cursor: pointer; +} + +/* Edit link cursor */ +.note-edit-btn { + cursor: pointer; +} + +/* === Dialog Styles === */ + +dialog.delete-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + border: 1px solid var(--color-base-border); + border-radius: 0.5rem; + background: var(--color-base-surface); + box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); + max-width: 28rem; + width: calc(100% - 2rem); +} + +dialog.delete-dialog::backdrop { + background: rgb(0 0 0 / 0.5); +} + +/* === Card Interactive Styles === */ + +/* Common card styles */ +.card-interactive { + @apply transition-colors duration-150 outline-none; + @apply hover:border-accent; + @apply focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2; +} + +/* === SSE Status Indicator === */ + +.sse-status { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.sse-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 9999px; + flex-shrink: 0; +} + +.sse-dot--connected { + background: var(--color-status-success); + animation: sse-pulse 2s ease-in-out infinite; +} + +.sse-dot--reconnecting { + background: var(--color-status-warning); + animation: sse-blink 1s ease-in-out infinite; +} + +.sse-dot--disconnected { + background: var(--color-status-error); +} + +@keyframes sse-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +@keyframes sse-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.2; } +} diff --git a/src/aspara/dashboard/static/css/tagger.css b/src/aspara/dashboard/static/css/tagger.css new file mode 100644 index 0000000000000000000000000000000000000000..a65022c7ceb1239ba2c5db00bfb5b3104d6bd414 --- /dev/null +++ b/src/aspara/dashboard/static/css/tagger.css @@ -0,0 +1,79 @@ +/**@license + * _____ + * |_ _|___ ___ ___ ___ ___ + * | | | .'| . | . | -_| _| + * |_| |__,|_ |_ |___|_| + * |___|___| version 0.6.2 + * + * Tagger - Zero dependency, Vanilla JavaScript Tag Editor + * + * Copyright (c) 2018-2024 Jakub T. Jankiewicz + * Released under the MIT license + */ +/* Border/background defined in input.css */ +.tagger input[type="hidden"] { + /* fix for bootstrap */ + display: none; +} +.tagger > ul { + display: flex; + width: 100%; + align-items: center; + padding: 4px 5px 0; + justify-content: space-between; + box-sizing: border-box; + height: auto; + flex: 0 0 auto; + overflow-y: auto; + margin: 0; + list-style: none; +} +.tagger > ul > li { + padding-bottom: 0.4rem; + margin: 0.4rem 5px 4px; +} +.tagger > ul > li:not(.tagger-new) a, +.tagger > ul > li:not(.tagger-new) a:visited { + text-decoration: none; + /* color defined in input.css */ +} +.tagger > ul > li:not(.tagger-new) > :first-child { + padding: 4px 4px 4px 8px; + /* background, border, border-radius defined in input.css */ +} +.tagger > ul > li:not(.tagger-new) > span, +.tagger > ul > li:not(.tagger-new) > a > span { + white-space: nowrap; +} +.tagger li a.close { + padding: 4px; + margin-left: 4px; + /* for bootstrap */ + float: none; + filter: alpha(opacity=100); + opacity: 1; + font-size: 16px; + line-height: 16px; +} +.tagger li a.close:hover { + /* color defined in input.css */ +} +.tagger .tagger-new input { + border: none; + outline: none; + box-shadow: none; + width: 100%; + padding-left: 0; + box-sizing: border-box; + background: transparent; +} +.tagger .tagger-new { + flex-grow: 1; + position: relative; + min-width: 40px; + width: 1px; +} +.tagger.wrap > ul { + flex-wrap: wrap; + justify-content: start; +} diff --git a/src/aspara/dashboard/static/favicon.ico b/src/aspara/dashboard/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f2277856c9a0ab0939a7f1d605a8d592ff4ba9e3 Binary files /dev/null and b/src/aspara/dashboard/static/favicon.ico differ diff --git a/src/aspara/dashboard/static/images/aspara-icon.png b/src/aspara/dashboard/static/images/aspara-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f2277856c9a0ab0939a7f1d605a8d592ff4ba9e3 Binary files /dev/null and b/src/aspara/dashboard/static/images/aspara-icon.png differ diff --git a/src/aspara/dashboard/static/js/api/delete-api.js b/src/aspara/dashboard/static/js/api/delete-api.js new file mode 100644 index 0000000000000000000000000000000000000000..0428cf69412fa9a624e41ea0872a063d5378fbf5 --- /dev/null +++ b/src/aspara/dashboard/static/js/api/delete-api.js @@ -0,0 +1,89 @@ +/** + * Delete API utility functions + * Pure API calls without UI logic + */ + +import { isDev } from '../dev-mode.js'; + +/** + * Parse an error response, returning a meaningful message. + * In dev mode, includes the raw response body for debugging. + * @param {Response} response - The failed fetch response + * @returns {Promise} Error message + */ +async function parseErrorResponse(response) { + let detail = 'Unknown error'; + let rawBody = null; + try { + const errorData = await response.json(); + detail = errorData.detail || detail; + } catch { + detail = `Server error: ${response.status}`; + if (isDev()) { + try { + rawBody = await response.text(); + } catch { + // ignore + } + } + } + if (isDev() && rawBody) { + return `${detail} (raw: ${rawBody.slice(0, 200)})`; + } + return detail; +} + +/** + * Delete a project via API + * @param {string} projectName - The project name to delete + * @returns {Promise} - Response data + * @throws {Error} - API error + */ +export async function deleteProjectApi(projectName) { + const response = await fetch(`/api/projects/${encodeURIComponent(projectName)}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (!response.ok) { + throw new Error(await parseErrorResponse(response)); + } + + // Handle 204 No Content responses + if (response.status === 204) { + return { message: 'Project deleted successfully' }; + } + + return response.json(); +} + +/** + * Delete a run via API + * @param {string} projectName - The project name + * @param {string} runName - The run name to delete + * @returns {Promise} - Response data + * @throws {Error} - API error + */ +export async function deleteRunApi(projectName, runName) { + const response = await fetch(`/api/projects/${encodeURIComponent(projectName)}/runs/${encodeURIComponent(runName)}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (!response.ok) { + throw new Error(await parseErrorResponse(response)); + } + + // Handle 204 No Content responses + if (response.status === 204) { + return { message: 'Run deleted successfully' }; + } + + return response.json(); +} diff --git a/src/aspara/dashboard/static/js/chart.js b/src/aspara/dashboard/static/js/chart.js new file mode 100644 index 0000000000000000000000000000000000000000..3ba8b9e75a68cc3d5984fbdc05e265edb98d2754 --- /dev/null +++ b/src/aspara/dashboard/static/js/chart.js @@ -0,0 +1,454 @@ +/** + * Canvas-based chart component for metrics visualization. + * Supports multiple series, zoom, hover tooltips, and data export. + */ +import { ChartColorPalette } from './chart/color-palette.js'; +import { ChartControls } from './chart/controls.js'; +import { ChartExport } from './chart/export.js'; +import { ChartInteraction } from './chart/interaction.js'; +import { ChartRenderer } from './chart/renderer.js'; +import { calculateDataRanges } from './chart/interaction-utils.js'; +import { YScale, computePaddedYRange, isLogScale, isValidLogValue, valueToChartY } from './chart/scale.js'; + +export class Chart { + // Chart layout constants + static MARGIN = 60; + static CANVAS_SCALE_FACTOR = 1.5; // Reduced from 2.5 for better performance + static SIZE_UPDATE_RETRY_DELAY_MS = 100; + static FULLSCREEN_UPDATE_DELAY_MS = 100; + static MIN_DRAG_DISTANCE = 10; + + // Grid constants + static X_GRID_COUNT = 10; + static Y_GRID_COUNT = 8; + static Y_PADDING_RATIO = 0.1; + + // Style constants + static LINE_WIDTH = 1.5; // Normal view + static LINE_WIDTH_FULLSCREEN = 2.5; // Fullscreen view + static GRID_LINE_WIDTH = 0.5; + static LEGEND_ITEM_SPACING = 16; + static LEGEND_LINE_LENGTH = 16; + static LEGEND_TEXT_OFFSET = 4; + static LEGEND_Y_OFFSET = 30; + + // Animation constants + static ANIMATION_PULSE_DURATION_MS = 1000; + + constructor(containerId, options = {}) { + this.container = document.querySelector(containerId); + if (!this.container) { + throw new Error(`Container ${containerId} not found`); + } + + this.data = null; + this.width = 0; + this.height = 0; + this.onZoomChange = options.onZoomChange || null; + this.onYScaleChange = options.onYScaleChange || null; + this.yScale = options.yScale || YScale.LINEAR; + + // Color palette for managing series styles + this.colorPalette = new ChartColorPalette(); + + // Initialize modules + this.renderer = new ChartRenderer(this); + this.chartExport = new ChartExport(this); + this.interaction = new ChartInteraction(this, this.renderer); + this.controls = new ChartControls(this, this.chartExport); + + this.hoverPoint = null; + + this.zoomState = { + active: false, + startX: null, + startY: null, + currentX: null, + currentY: null, + }; + this.zoom = { x: null, y: null }; + + // Fullscreen event handler (stored for cleanup) + this.fullscreenChangeHandler = null; + + // Data range cache for performance optimization + this._cachedDataRanges = null; + this._lastDataRef = null; + this._lastScale = null; + + this.init(); + } + + init() { + this.container.innerHTML = ''; + + this.canvas = document.createElement('canvas'); + this.canvas.style.border = '1px solid #e5e7eb'; + this.canvas.style.display = 'block'; + this.canvas.style.maxWidth = '100%'; + + // Accessibility: expose the canvas as a decorative image whose + // accessible name is derived from the chart's

title via + // aria-labelledby. The title id is stashed on the container div by + // createMetricChartContainer(); when absent (e.g. standalone Chart + // usage) fall back to a generic label. + this.canvas.setAttribute('role', 'img'); + const titleId = this.container.dataset.ariaLabelledby; + if (titleId) { + this.canvas.setAttribute('aria-labelledby', titleId); + } else { + this.canvas.setAttribute('aria-label', 'Metrics line chart'); + } + + this.container.appendChild(this.canvas); + this.ctx = this.canvas.getContext('2d'); + + this.ctx.imageSmoothingEnabled = true; + this.ctx.imageSmoothingQuality = 'high'; + + // For throttling draw calls + this.pendingDraw = false; + + this.updateSize(); + this.interaction.setupEventListeners(); + this.setupFullscreenListener(); + this.controls.create(); + } + + updateSize() { + // Use clientWidth/clientHeight to get size excluding border + const rect = this.container.getBoundingClientRect?.(); + const width = this.container.clientWidth || rect?.width || 0; + const height = this.container.clientHeight || rect?.height || 0; + + // Retry later if container is not yet visible + if (width === 0 || height === 0) { + // Avoid infinite retry loops - only retry if we haven't set a size yet + if (this.width === 0 && this.height === 0) { + setTimeout(() => this.updateSize(), Chart.SIZE_UPDATE_RETRY_DELAY_MS); + } + return; + } + + this.width = width; + this.height = height; + + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + + const dpr = window.devicePixelRatio || 1; + const totalScale = dpr * Chart.CANVAS_SCALE_FACTOR; + + // Set internal canvas resolution (high-DPI) + this.canvas.width = this.width * totalScale; + this.canvas.height = this.height * totalScale; + + // Set CSS display size to exact pixel values (matching internal aspect ratio) + this.canvas.style.width = `${this.width}px`; + this.canvas.style.height = `${this.height}px`; + this.canvas.style.display = 'block'; + + this.ctx.scale(totalScale, totalScale); + + // Redraw if data is already set + if (this.data) { + this.draw(); + } + } + + setData(data) { + this.data = data; + // Invalidate data range cache when data changes + this._cachedDataRanges = null; + this._lastDataRef = null; + if (data?.series) { + this.colorPalette.ensureRunStyles(data.series.map((s) => s.name)); + } + this.draw(); + } + + /** + * Get cached data ranges, recalculating only when data or scale changes. + * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null + */ + _getDataRanges() { + if (this.data?.series !== this._lastDataRef || this.yScale !== this._lastScale) { + this._lastDataRef = this.data?.series; + this._lastScale = this.yScale; + this._cachedDataRanges = this._calculateDataRanges(); + } + return this._cachedDataRanges; + } + + /** + * Calculate data ranges from all series. + * For log scale, only positive values are considered. + * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null + */ + _calculateDataRanges() { + return calculateDataRanges(this.data?.series || [], this.yScale); + } + + draw() { + // Skip drawing if canvas size is not yet initialized + if (this.width === 0 || this.height === 0) { + return; + } + + this.ctx.fillStyle = 'white'; + this.ctx.fillRect(0, 0, this.width, this.height); + + if (!this.data) { + console.warn('Chart.draw(): No data set'); + return; + } + + if (!this.data.series || !Array.isArray(this.data.series)) { + console.error('Chart.draw(): Invalid data format - series must be an array'); + return; + } + + if (this.data.series.length === 0) { + console.warn('Chart.draw(): Empty series array'); + return; + } + + const margin = Chart.MARGIN; + const plotWidth = this.width - margin * 2; + const plotHeight = this.height - margin * 2; + + // Use cached data ranges for performance + const ranges = this._getDataRanges(); + if (!ranges) { + console.warn('Chart.draw(): No valid data points in series'); + return; + } + + let { xMin, xMax, yMin, yMax } = ranges; + + if (this.zoom.x) { + xMin = this.zoom.x.min; + xMax = this.zoom.x.max; + } + if (this.zoom.y) { + yMin = this.zoom.y.min; + yMax = this.zoom.y.max; + } + + if (isLogScale(this.yScale) && (yMin <= 0 || yMax <= 0)) { + this.renderer.drawMessage('Log scale requires positive y values'); + return; + } + + const { yMinPadded, yMaxPadded } = computePaddedYRange(yMin, yMax, this.yScale, Chart.Y_PADDING_RATIO); + + this.renderer.drawGrid(margin, plotWidth, plotHeight, xMin, xMax, yMinPadded, yMaxPadded, this.yScale); + this.renderer.drawAxisLabels(margin, plotWidth, plotHeight, xMin, xMax, yMinPadded, yMaxPadded, this.yScale); + + // Clip to plot area + this.ctx.save(); + this.ctx.beginPath(); + this.ctx.rect(margin, margin, plotWidth, plotHeight); + this.ctx.clip(); + + for (const series of this.data.series) { + if (!series.data?.steps?.length) continue; + const { steps, values } = series.data; + + const style = this.colorPalette.getRunStyle(series.name); + + this.ctx.strokeStyle = style.borderColor; + this.ctx.lineWidth = this.getLineWidth(); + this.ctx.lineCap = 'round'; + this.ctx.lineJoin = 'round'; + + // Apply border dash pattern + if (style.borderDash && style.borderDash.length > 0) { + this.ctx.setLineDash(style.borderDash); + } else { + this.ctx.setLineDash([]); + } + + this.ctx.beginPath(); + let hasValidPoint = false; + + for (let i = 0; i < steps.length; i++) { + const value = values[i]; + if (isLogScale(this.yScale) && !isValidLogValue(value)) { + // Break the line at non-positive values; resume at the next valid point. + if (hasValidPoint) { + this.ctx.stroke(); + this.ctx.beginPath(); + hasValidPoint = false; + } + continue; + } + + const x = margin + ((steps[i] - xMin) / (xMax - xMin)) * plotWidth; + const y = valueToChartY(value, this.yScale, plotHeight, margin, yMinPadded, yMaxPadded); + + if (!hasValidPoint) { + this.ctx.moveTo(x, y); + hasValidPoint = true; + } else { + this.ctx.lineTo(x, y); + } + } + + this.ctx.stroke(); + this.ctx.setLineDash([]); // Reset dash pattern + } + + this.ctx.restore(); + this.renderer.drawLegend(); + this.interaction.drawHoverEffects(); + this.interaction.drawZoomSelection(); + } + + getLineWidth() { + if (document.fullscreenElement === this.container) { + return Chart.LINE_WIDTH_FULLSCREEN; + } + return Chart.LINE_WIDTH; + } + + getRunStyle(seriesName) { + return this.colorPalette.getRunStyle(seriesName); + } + + setupFullscreenListener() { + // Store handler for cleanup + this.fullscreenChangeHandler = () => { + setTimeout(() => { + this.updateSize(); + }, Chart.FULLSCREEN_UPDATE_DELAY_MS); + }; + document.addEventListener('fullscreenchange', this.fullscreenChangeHandler); + } + + resetZoom() { + this.zoom.x = null; + this.zoom.y = null; + this.draw(); + } + + /** + * Toggle the y-axis scale between linear and logarithmic. + * Zoom is cleared because a linear zoom range does not translate to log space. + */ + toggleYScale() { + this.setYScale(this.yScale === YScale.LOG ? YScale.LINEAR : YScale.LOG); + } + + /** + * Set the y-axis scale explicitly. + * @param {string} scale - YScale.LINEAR or YScale.LOG + */ + setYScale(scale) { + if (scale !== YScale.LINEAR && scale !== YScale.LOG) return; + this.yScale = scale; + this.zoom.y = null; + this.hoverPoint = null; + if (this.onYScaleChange) { + this.onYScaleChange(this.yScale); + } + this.draw(); + } + + setExternalZoom(zoomState) { + if (zoomState?.x) { + this.zoom.x = { ...zoomState.x }; + this.draw(); + } + } + + /** + * Add a new data point to an existing series (SoA format) + * @param {string} runName - Name of the run + * @param {number} step - Step number + * @param {number} value - Metric value + */ + addDataPoint(runName, step, value) { + console.log(`[Chart] addDataPoint called: run=${runName}, step=${step}, value=${value}`); + + if (!this.data || !this.data.series) { + console.warn('[Chart] No data or series available'); + return; + } + + // Find the series for this run + let series = this.data.series.find((s) => s.name === runName); + + if (!series) { + // Create new series if it doesn't exist (SoA format) + series = { + name: runName, + data: { steps: [], values: [] }, + }; + this.data.series.push(series); + } + + const { steps, values } = series.data; + + // Binary search to find insertion position + let left = 0; + let right = steps.length; + while (left < right) { + const mid = (left + right) >> 1; + if (steps[mid] < step) { + left = mid + 1; + } else if (steps[mid] > step) { + right = mid; + } else { + // Exact match - update existing value + values[mid] = value; + this.scheduleDraw(); + return; + } + } + + // Insert at the found position (usually at the end, so O(1) in practice) + steps.splice(left, 0, step); + values.splice(left, 0, value); + + // Invalidate data range cache when data changes + this._cachedDataRanges = null; + + // Schedule redraw using requestAnimationFrame to throttle updates + this.scheduleDraw(); + } + + /** + * Schedule a draw operation using requestAnimationFrame + * This prevents excessive redraws when multiple data points arrive rapidly + */ + scheduleDraw() { + console.log('[Chart] scheduleDraw called, pendingDraw:', this.pendingDraw); + + if (this.pendingDraw) { + return; // Draw already scheduled + } + + this.pendingDraw = true; + requestAnimationFrame(() => { + console.log('[Chart] requestAnimationFrame callback executing'); + this.pendingDraw = false; + this.draw(); + }); + } + + /** + * Clean up event listeners and resources. + */ + destroy() { + if (this.fullscreenChangeHandler) { + document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler); + this.fullscreenChangeHandler = null; + } + if (this.interaction) { + this.interaction.destroy(); + } + if (this.controls) { + this.controls.destroy(); + } + } +} diff --git a/src/aspara/dashboard/static/js/chart/color-palette.js b/src/aspara/dashboard/static/js/chart/color-palette.js new file mode 100644 index 0000000000000000000000000000000000000000..3e485eba61df4a12381c917f2e108790cb9a3b59 --- /dev/null +++ b/src/aspara/dashboard/static/js/chart/color-palette.js @@ -0,0 +1,198 @@ +/** + * ChartColorPalette - Color management for chart series + * Handles color generation, style assignment, and run-to-style mapping + */ +export class ChartColorPalette { + constructor() { + // Modern 16-color base palette with well-distributed hues for easy differentiation + // Colors are arranged by hue (0-360°) with ~22.5° spacing for maximum visual distinction + // Red-family colors and dark blues have varied saturation for better distinction + this.baseColors = [ + '#FF3B47', // red (0°) - high saturation + '#F77F00', // orange (30°) + '#FCBF49', // yellow (45°) + '#06D6A0', // mint/turquoise (165°) + '#118AB2', // blue (195°) + '#69808b', // dark blue (200°) - higher saturation, more vivid + '#4361EE', // bright blue (225°) + '#7209B7', // purple (270°) + '#E85D9A', // magenta (330°) - medium saturation, lighter + '#B8252D', // crimson (355°) - lower saturation, darker + '#F4A261', // peach (35°) + '#2A9D8F', // teal (170°) + '#408828', // dark teal (190°) - lower saturation, more muted + '#3A86FF', // sky blue (215°) + '#8338EC', // violet (265°) + '#FF1F7D', // hot pink (340°) - very high saturation + ]; + + // Border dash patterns for additional differentiation + this.borderDashPatterns = [ + [], // solid + [6, 4], // dashed + [2, 3], // dotted + [10, 3, 2, 3], // dash-dot + ]; + + // Registry to maintain stable run->style mapping + this.runStyleRegistry = new Map(); + this.nextStyleIndex = 0; + } + + /** + * Convert hex color to RGB + * @param {string} hex - Hex color string + * @returns {Object|null} RGB object or null if invalid + */ + hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: Number.parseInt(result[1], 16), + g: Number.parseInt(result[2], 16), + b: Number.parseInt(result[3], 16), + } + : null; + } + + /** + * Convert RGB to HSL + * @param {number} r - Red (0-255) + * @param {number} g - Green (0-255) + * @param {number} b - Blue (0-255) + * @returns {Object} HSL object + */ + rgbToHsl(r, g, b) { + const rNorm = r / 255; + const gNorm = g / 255; + const bNorm = b / 255; + + const max = Math.max(rNorm, gNorm, bNorm); + const min = Math.min(rNorm, gNorm, bNorm); + let h; + let s; + const l = (max + min) / 2; + + if (max === min) { + h = 0; + s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case rNorm: + h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6; + break; + case gNorm: + h = ((bNorm - rNorm) / d + 2) / 6; + break; + case bNorm: + h = ((rNorm - gNorm) / d + 4) / 6; + break; + } + } + + return { h: h * 360, s: s * 100, l: l * 100 }; + } + + /** + * Apply variant transformation to HSL color + * @param {Object} hsl - HSL color object + * @param {number} variantIndex - Variant index (0-2) + * @returns {Object} Modified HSL object + */ + applyVariant(hsl, variantIndex) { + const variants = [ + { sDelta: 0, lDelta: 0 }, // normal + { sDelta: -15, lDelta: -6 }, // muted + { sDelta: 8, lDelta: 6 }, // bright + ]; + + const variant = variants[variantIndex]; + let s = hsl.s + variant.sDelta; + let l = hsl.l + variant.lDelta; + + // Clamp to safe ranges + s = Math.max(35, Math.min(95, s)); + l = Math.max(30, Math.min(70, l)); + + return { h: hsl.h, s, l }; + } + + /** + * Convert HSL to CSS string + * @param {Object} hsl - HSL color object + * @returns {string} CSS HSL string + */ + hslToString(hsl) { + return `hsl(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%)`; + } + + /** + * Generate style for a given style index + * @param {number} styleIndex - Style index + * @returns {Object} Style object with borderColor, backgroundColor, borderDash + */ + generateStyle(styleIndex) { + const M = this.baseColors.length; // 16 + const V = 3; // variants + const D = this.borderDashPatterns.length; // 4 + + const baseIndex = styleIndex % M; + const variantIndex = Math.floor(styleIndex / M) % V; + const dashIndex = Math.floor(styleIndex / (M * V)) % D; + + // Get base color and convert to HSL + const hex = this.baseColors[baseIndex]; + const rgb = this.hexToRgb(hex); + const hsl = this.rgbToHsl(rgb.r, rgb.g, rgb.b); + + // Apply variant + const variantHsl = this.applyVariant(hsl, variantIndex); + const borderColor = this.hslToString(variantHsl); + + // Get border dash pattern + const borderDash = this.borderDashPatterns[dashIndex]; + + return { + borderColor, + backgroundColor: borderColor, + borderDash, + }; + } + + /** + * Ensure all runs have stable styles assigned + * @param {Array} runIds - Array of run IDs + */ + ensureRunStyles(runIds) { + // Sort run IDs for stable ordering + const sortedRunIds = [...new Set(runIds)].sort(); + + for (const runId of sortedRunIds) { + if (!this.runStyleRegistry.has(runId)) { + const style = this.generateStyle(this.nextStyleIndex); + this.runStyleRegistry.set(runId, style); + this.nextStyleIndex++; + } + } + } + + /** + * Get style for a specific run + * @param {string} runId - Run ID + * @returns {Object} Style object + */ + getRunStyle(runId) { + return this.runStyleRegistry.get(runId) || this.generateStyle(0); + } + + /** + * Reset the style registry + */ + reset() { + this.runStyleRegistry.clear(); + this.nextStyleIndex = 0; + } +} diff --git a/src/aspara/dashboard/static/js/chart/controls.js b/src/aspara/dashboard/static/js/chart/controls.js new file mode 100644 index 0000000000000000000000000000000000000000..1fa29945f73035d5b3a353d6edd30a4e6a5f7407 --- /dev/null +++ b/src/aspara/dashboard/static/js/chart/controls.js @@ -0,0 +1,467 @@ +import { CHART_CONTROL_LABELS, ICON_DOWNLOAD, ICON_FULLSCREEN, ICON_HELP, ICON_RESET_ZOOM } from '../html-utils.js'; + +/** + * Apply shared styling and ARIA attributes to a chart control button. + * @param {HTMLButtonElement} button + * @param {string} label - Accessible name (also used as tooltip) + */ +function styleControlButton(button, label) { + button.type = 'button'; + button.setAttribute('aria-label', label); + button.title = label; + button.style.cssText = ` + width: 32px; + height: 32px; + border: 1px solid #ddd; + background: white; + cursor: pointer; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + color: #555; + `; +} + +export class ChartControls { + constructor(chart, chartExport) { + this.chart = chart; + this.chartExport = chartExport; + this.buttonContainer = null; + this.resetButton = null; + this.fullSizeButton = null; + this.downloadButton = null; + this.downloadMenu = null; + this.helpButton = null; + this.helpPopover = null; + this.logScaleControl = null; + this.logScaleCheckbox = null; + + // Document click handler (stored for cleanup) + this.documentClickHandler = null; + // fullscreenchange handler (stored for cleanup) + this.fullscreenChangeHandler = null; + // Keydown handler for Esc / arrow keys inside open menus (stored for cleanup) + this._menuKeydownHandler = null; + } + + create() { + this.chart.container.style.position = 'relative'; + + this.buttonContainer = document.createElement('div'); + this.buttonContainer.style.cssText = ` + position: absolute; + top: 10px; + right: 10px; + display: flex; + gap: 8px; + z-index: 10; + `; + + this.createLogScaleControl(); + this.createResetButton(); + this.createFullSizeButton(); + this.createDownloadButton(); + this.createHelpButton(); + + this.buttonContainer.appendChild(this.logScaleControl); + this.buttonContainer.appendChild(this.resetButton); + this.buttonContainer.appendChild(this.fullSizeButton); + this.buttonContainer.appendChild(this.downloadButton); + this.buttonContainer.appendChild(this.helpButton); + this.chart.container.appendChild(this.buttonContainer); + + this.updateLogScaleControl(); + // Wire up callback so the checkbox reflects scale changes from other sources + // (e.g. keyboard shortcuts) without coupling Chart to ChartControls. + const existingYScaleCallback = this.chart.onYScaleChange; + this.chart.onYScaleChange = (scale) => { + if (existingYScaleCallback) existingYScaleCallback(scale); + this.updateLogScaleControl(); + }; + } + + createResetButton() { + this.resetButton = document.createElement('button'); + this.resetButton.innerHTML = ICON_RESET_ZOOM; + styleControlButton(this.resetButton, CHART_CONTROL_LABELS.resetZoom); + + this.attachButtonHover(this.resetButton); + this.resetButton.addEventListener('click', () => this.chart.resetZoom()); + } + + createFullSizeButton() { + this.fullSizeButton = document.createElement('button'); + this.fullSizeButton.innerHTML = ICON_FULLSCREEN; + styleControlButton(this.fullSizeButton, CHART_CONTROL_LABELS.enterFullscreen); + + this.attachButtonHover(this.fullSizeButton); + this.fullSizeButton.addEventListener('click', () => this.fitToFullSize()); + + // Keep the title/aria-label in sync with the actual fullscreen state so + // the tooltip reflects what clicking the button will do (e.g. after the + // user exits fullscreen via Esc). + this.fullscreenChangeHandler = () => { + if (!this.fullSizeButton) return; + const label = document.fullscreenElement + ? CHART_CONTROL_LABELS.exitFullscreen + : CHART_CONTROL_LABELS.enterFullscreen; + this.fullSizeButton.title = label; + this.fullSizeButton.setAttribute('aria-label', label); + }; + document.addEventListener('fullscreenchange', this.fullscreenChangeHandler); + } + + createDownloadButton() { + this.downloadButton = document.createElement('button'); + this.downloadButton.innerHTML = ICON_DOWNLOAD; + styleControlButton(this.downloadButton, CHART_CONTROL_LABELS.download); + this.downloadButton.style.position = 'relative'; + // Declare the popup menu relationship for AT users. + this.downloadButton.setAttribute('aria-haspopup', 'menu'); + this.downloadButton.setAttribute('aria-expanded', 'false'); + + this.downloadMenu = document.createElement('div'); + this.downloadMenu.setAttribute('role', 'menu'); + this.downloadMenu.style.cssText = ` + position: absolute; + top: 100%; + right: 0; + background: white; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + display: none; + flex-direction: column; + width: 120px; + z-index: 20; + `; + + const downloadOptions = [ + { format: 'CSV', label: 'CSV format' }, + { format: 'SVG', label: 'SVG image' }, + { format: 'PNG', label: 'PNG image' }, + ]; + + for (const option of downloadOptions) { + const menuItem = document.createElement('button'); + menuItem.textContent = option.label; + menuItem.setAttribute('role', 'menuitem'); + menuItem.style.cssText = ` + padding: 8px 12px; + text-align: left; + background: none; + border: none; + cursor: pointer; + font-size: 13px; + color: #333; + `; + menuItem.addEventListener('mouseenter', () => { + menuItem.style.background = '#f5f5f5'; + }); + menuItem.addEventListener('mouseleave', () => { + menuItem.style.background = 'none'; + }); + menuItem.addEventListener('click', (e) => { + e.stopPropagation(); + this.chartExport.downloadData(option.format); + this.toggleDownloadMenu(false); + }); + this.downloadMenu.appendChild(menuItem); + } + + this.downloadButton.appendChild(this.downloadMenu); + + this.attachButtonHover(this.downloadButton); + this.downloadButton.addEventListener('click', () => this.toggleDownloadMenu()); + } + + createLogScaleControl() { + const controlId = `logscale-${Math.random().toString(36).slice(2, 9)}`; + + this.logScaleControl = document.createElement('label'); + this.logScaleControl.htmlFor = controlId; + this.logScaleControl.style.cssText = ` + height: 32px; + border: 1px solid #ddd; + background: white; + border-radius: 6px; + display: flex; + align-items: center; + padding: 0 10px; + gap: 6px; + font-size: 12px; + color: #555; + cursor: pointer; + user-select: none; + `; + + this.logScaleCheckbox = document.createElement('input'); + this.logScaleCheckbox.type = 'checkbox'; + this.logScaleCheckbox.id = controlId; + this.logScaleCheckbox.setAttribute('aria-label', CHART_CONTROL_LABELS.toggleLogScale); + this.logScaleCheckbox.style.cursor = 'pointer'; + + const labelText = document.createElement('span'); + labelText.textContent = 'logscale'; + + this.logScaleControl.appendChild(this.logScaleCheckbox); + this.logScaleControl.appendChild(labelText); + + this.logScaleControl.addEventListener('mouseenter', () => { + this.logScaleControl.style.background = '#f5f5f5'; + this.logScaleControl.style.borderColor = '#bbb'; + }); + this.logScaleControl.addEventListener('mouseleave', () => { + this.logScaleControl.style.background = 'white'; + this.logScaleControl.style.borderColor = '#ddd'; + }); + + this.logScaleCheckbox.addEventListener('change', () => { + this.chart.toggleYScale(); + }); + } + + updateLogScaleControl() { + if (!this.logScaleCheckbox) return; + const isLog = this.chart.yScale === 'log'; + this.logScaleCheckbox.checked = isLog; + } + + createHelpButton() { + this.helpButton = document.createElement('button'); + this.helpButton.innerHTML = ICON_HELP; + styleControlButton(this.helpButton, CHART_CONTROL_LABELS.help); + this.helpButton.style.position = 'relative'; + // The help popover is informational, not a menu, but it is a popup. + this.helpButton.setAttribute('aria-haspopup', 'dialog'); + this.helpButton.setAttribute('aria-expanded', 'false'); + + this.helpPopover = document.createElement('div'); + this.helpPopover.setAttribute('role', 'dialog'); + this.helpPopover.style.cssText = ` + position: absolute; + top: 100%; + right: 0; + background: white; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + display: none; + flex-direction: column; + width: 220px; + z-index: 20; + padding: 10px 12px; + `; + + // SSOT: chart interaction descriptions live here. Each button's + // `title` attribute stays short; this popover is the canonical place + // for the full explanation. + const items = [ + { icon: 'āœ‹', text: 'Drag on chart to zoom' }, + { icon: '↺', text: 'Click reset to restore view' }, + { icon: 'ā›¶', text: 'Click fullscreen to expand' }, + { icon: '⤓', text: 'Click download to export data' }, + { icon: 'log', text: 'Check logscale to toggle y-axis scale' }, + ]; + + for (const item of items) { + const row = document.createElement('div'); + row.style.cssText = ` + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 13px; + color: #333; + `; + const iconSpan = document.createElement('span'); + iconSpan.textContent = item.icon; + iconSpan.setAttribute('aria-hidden', 'true'); + iconSpan.style.cssText = 'width: 18px; text-align: center; flex-shrink: 0;'; + const textSpan = document.createElement('span'); + textSpan.textContent = item.text; + row.appendChild(iconSpan); + row.appendChild(textSpan); + this.helpPopover.appendChild(row); + } + + this.helpButton.appendChild(this.helpPopover); + + this.attachButtonHover(this.helpButton); + this.helpButton.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleHelpPopover(); + }); + + // Reuse the document click handler pattern from the download menu to + // close the help popover when clicking outside. + const existingHandler = this.documentClickHandler; + this.documentClickHandler = (e) => { + if (this.downloadButton && !this.downloadButton.contains(e.target)) { + this.toggleDownloadMenu(false); + } + if (this.helpButton && !this.helpButton.contains(e.target)) { + this.toggleHelpPopover(false); + } + }; + // Replace the previously registered handler (if any) with the unified one. + if (existingHandler) { + document.removeEventListener('click', existingHandler); + } + document.addEventListener('click', this.documentClickHandler); + + // Global keydown handler: Esc closes the open popup and returns focus + // to the triggering button; arrow keys move between menu items of the + // download menu (ARIA Authoring Practices menu pattern). + this._menuKeydownHandler = (e) => this._handleMenuKeydown(e); + document.addEventListener('keydown', this._menuKeydownHandler); + } + + /** + * Handle keyboard navigation for open popups (Esc / Arrow / Home / End). + * Follows the ARIA Authoring Practices Guide menu pattern. + */ + _handleMenuKeydown(e) { + const downloadOpen = this.downloadMenu?.style.display === 'flex'; + const helpOpen = this.helpPopover?.style.display === 'flex'; + if (!downloadOpen && !helpOpen) return; + + // Esc closes whichever popup is open and returns focus to its trigger. + if (e.key === 'Escape') { + e.preventDefault(); + if (downloadOpen) { + this.toggleDownloadMenu(false); + this.downloadButton?.focus(); + } else if (helpOpen) { + this.toggleHelpPopover(false); + this.helpButton?.focus(); + } + return; + } + + // Arrow / Home / End only apply to the download menu (role=menu). + if (!downloadOpen) return; + + const items = Array.from(this.downloadMenu.querySelectorAll('[role="menuitem"]')); + if (items.length === 0) return; + const currentIndex = items.indexOf(document.activeElement); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + items[(currentIndex + 1) % items.length].focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + items[(currentIndex - 1 + items.length) % items.length].focus(); + } else if (e.key === 'Home') { + e.preventDefault(); + items[0].focus(); + } else if (e.key === 'End') { + e.preventDefault(); + items[items.length - 1].focus(); + } + } + + toggleHelpPopover(forceState) { + if (!this.helpPopover) return; + const isVisible = this.helpPopover.style.display === 'flex'; + const newState = forceState !== undefined ? forceState : !isVisible; + this.helpPopover.style.display = newState ? 'flex' : 'none'; + if (this.helpButton) { + this.helpButton.setAttribute('aria-expanded', newState ? 'true' : 'false'); + } + if (newState) { + // Move focus into the dialog so AT users perceive the context switch. + this.helpPopover.focus?.(); + } + } + + fitToFullSize() { + if (!document.fullscreenElement) { + if (this.chart.container.requestFullscreen) { + this.chart.container.requestFullscreen(); + } else if (this.chart.container.webkitRequestFullscreen) { + this.chart.container.webkitRequestFullscreen(); + } else if (this.chart.container.mozRequestFullScreen) { + this.chart.container.mozRequestFullScreen(); + } else if (this.chart.container.msRequestFullscreen) { + this.chart.container.msRequestFullscreen(); + } + this.fullSizeButton.title = CHART_CONTROL_LABELS.exitFullscreen; + this.fullSizeButton.setAttribute('aria-label', CHART_CONTROL_LABELS.exitFullscreen); + } else { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } + this.fullSizeButton.title = CHART_CONTROL_LABELS.enterFullscreen; + this.fullSizeButton.setAttribute('aria-label', CHART_CONTROL_LABELS.enterFullscreen); + } + } + + toggleDownloadMenu(forceState) { + const isVisible = this.downloadMenu.style.display === 'flex'; + const newState = forceState !== undefined ? forceState : !isVisible; + + this.downloadMenu.style.display = newState ? 'flex' : 'none'; + if (this.downloadButton) { + this.downloadButton.setAttribute('aria-expanded', newState ? 'true' : 'false'); + } + if (newState) { + // Move focus to the first menu item so keyboard users can + // immediately navigate with arrows (ARIA APG menu pattern). + const firstItem = this.downloadMenu.querySelector('[role="menuitem"]'); + firstItem?.focus(); + } + } + + /** + * Attach hover effect to a button element + * @param {HTMLButtonElement} button - Button element to attach hover effect + */ + attachButtonHover(button) { + button.addEventListener('mouseenter', () => { + button.style.background = '#f5f5f5'; + button.style.borderColor = '#bbb'; + }); + button.addEventListener('mouseleave', () => { + button.style.background = 'white'; + button.style.borderColor = '#ddd'; + }); + } + + /** + * Clean up event listeners and remove DOM elements. + */ + destroy() { + if (this.documentClickHandler) { + document.removeEventListener('click', this.documentClickHandler); + this.documentClickHandler = null; + } + if (this.fullscreenChangeHandler) { + document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler); + this.fullscreenChangeHandler = null; + } + if (this._menuKeydownHandler) { + document.removeEventListener('keydown', this._menuKeydownHandler); + this._menuKeydownHandler = null; + } + if (this.buttonContainer?.parentNode) { + this.buttonContainer.parentNode.removeChild(this.buttonContainer); + } + this.buttonContainer = null; + this.resetButton = null; + this.fullSizeButton = null; + this.downloadButton = null; + this.downloadMenu = null; + this.helpButton = null; + this.helpPopover = null; + this.logScaleControl = null; + this.logScaleCheckbox = null; + } +} diff --git a/src/aspara/dashboard/static/js/chart/export-utils.js b/src/aspara/dashboard/static/js/chart/export-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..f320b39081b6f0ec09d3b78ec21c4bb0127ff022 --- /dev/null +++ b/src/aspara/dashboard/static/js/chart/export-utils.js @@ -0,0 +1,74 @@ +/** + * Pure utility functions for chart export + * These functions have no side effects and are easy to test + */ + +/** + * Generate CSV content from series data (SoA format) + * @param {Array} series - Array of series objects with name and data in SoA format + * @returns {string} CSV formatted string + */ +export function generateCSVContent(series) { + const lines = ['series,step,value']; + + for (const s of series) { + if (!s.data?.steps?.length) continue; + const { steps, values } = s.data; + + const seriesName = s.name.replace(/"/g, '""'); + + for (let i = 0; i < steps.length; i++) { + lines.push(`"${seriesName}",${steps[i]},${values[i]}`); + } + } + + return `${lines.join('\n')}\n`; +} + +/** + * Sanitize a string for use as a filename + * @param {string} name - Original name + * @returns {string} Sanitized filename + */ +export function sanitizeFileName(name) { + return name.replace(/[^a-z0-9]/gi, '_').toLowerCase(); +} + +/** + * Get export filename from chart data + * @param {Object} data - Chart data object with optional title and series + * @returns {string} Filename without extension + */ +export function getExportFileName(data) { + if (data.title) { + return sanitizeFileName(data.title); + } + if (data.series && data.series.length === 1) { + return sanitizeFileName(data.series[0].name); + } + return 'chart'; +} + +/** + * Calculate dimensions for zoomed/unzoomed export + * @param {Object} chart - Chart object with zoom, width, height, and MARGIN + * @returns {Object} Dimensions info including useZoomedArea, margin, plotWidth, plotHeight + */ +export function calculateExportDimensions(chart) { + const useZoomedArea = chart.zoom.x !== null || chart.zoom.y !== null; + const margin = chart.constructor.MARGIN; + const plotWidth = chart.width - margin * 2; + const plotHeight = chart.height - margin * 2; + + return { useZoomedArea, margin, plotWidth, plotHeight }; +} + +/** + * Build filename with optional zoom suffix + * @param {string} baseName - Base filename + * @param {boolean} isZoomed - Whether to add zoomed suffix + * @returns {string} Final filename + */ +export function buildExportFileName(baseName, isZoomed) { + return isZoomed ? `${baseName}_zoomed` : baseName; +} diff --git a/src/aspara/dashboard/static/js/chart/export.js b/src/aspara/dashboard/static/js/chart/export.js new file mode 100644 index 0000000000000000000000000000000000000000..47973df715abf61ad70b9c8c3b75a09cd978f9fc --- /dev/null +++ b/src/aspara/dashboard/static/js/chart/export.js @@ -0,0 +1,140 @@ +import { buildExportFileName, calculateExportDimensions, generateCSVContent, getExportFileName } from './export-utils.js'; + +export class ChartExport { + constructor(chart) { + this.chart = chart; + } + + downloadData(format) { + if (!this.chart.data || !this.chart.data.series || this.chart.data.series.length === 0) { + return; + } + + switch (format) { + case 'CSV': + this.downloadCSV(); + break; + case 'SVG': + this.downloadSVG(); + break; + case 'PNG': + this.downloadPNG(); + break; + } + } + + downloadCSV() { + const csvContent = generateCSVContent(this.chart.data.series); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + const fileName = getExportFileName(this.chart.data); + + link.setAttribute('href', url); + link.setAttribute('download', `${fileName}.csv`); + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + + downloadSVG() { + const svgNamespace = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(svgNamespace, 'svg'); + + const { useZoomedArea, margin, plotWidth, plotHeight } = calculateExportDimensions(this.chart); + + if (useZoomedArea) { + svg.setAttribute('width', plotWidth); + svg.setAttribute('height', plotHeight); + svg.setAttribute('viewBox', `0 0 ${plotWidth} ${plotHeight}`); + + const background = document.createElementNS(svgNamespace, 'rect'); + background.setAttribute('width', plotWidth); + background.setAttribute('height', plotHeight); + background.setAttribute('fill', 'white'); + svg.appendChild(background); + + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = plotWidth; + tempCanvas.height = plotHeight; + const tempCtx = tempCanvas.getContext('2d'); + + tempCtx.drawImage(this.chart.canvas, margin, margin, plotWidth, plotHeight, 0, 0, plotWidth, plotHeight); + + const canvasImage = document.createElementNS(svgNamespace, 'image'); + canvasImage.setAttribute('width', plotWidth); + canvasImage.setAttribute('height', plotHeight); + canvasImage.setAttribute('href', tempCanvas.toDataURL('image/png')); + svg.appendChild(canvasImage); + } else { + svg.setAttribute('width', this.chart.width); + svg.setAttribute('height', this.chart.height); + svg.setAttribute('viewBox', `0 0 ${this.chart.width} ${this.chart.height}`); + + const background = document.createElementNS(svgNamespace, 'rect'); + background.setAttribute('width', this.chart.width); + background.setAttribute('height', this.chart.height); + background.setAttribute('fill', 'white'); + svg.appendChild(background); + + const canvasImage = document.createElementNS(svgNamespace, 'image'); + canvasImage.setAttribute('width', this.chart.width); + canvasImage.setAttribute('height', this.chart.height); + canvasImage.setAttribute('href', this.chart.canvas.toDataURL('image/png')); + svg.appendChild(canvasImage); + } + + const serializer = new XMLSerializer(); + const svgString = serializer.serializeToString(svg); + + const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + const fileName = buildExportFileName(getExportFileName(this.chart.data), useZoomedArea); + + link.setAttribute('href', url); + link.setAttribute('download', `${fileName}.svg`); + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + + downloadPNG() { + const { useZoomedArea, margin, plotWidth, plotHeight } = calculateExportDimensions(this.chart); + + let dataURL; + + if (useZoomedArea) { + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = plotWidth; + tempCanvas.height = plotHeight; + const tempCtx = tempCanvas.getContext('2d'); + + tempCtx.drawImage(this.chart.canvas, margin, margin, plotWidth, plotHeight, 0, 0, plotWidth, plotHeight); + + dataURL = tempCanvas.toDataURL('image/png'); + } else { + dataURL = this.chart.canvas.toDataURL('image/png'); + } + + const fileName = buildExportFileName(getExportFileName(this.chart.data), useZoomedArea); + + const link = document.createElement('a'); + link.setAttribute('href', dataURL); + link.setAttribute('download', `${fileName}.png`); + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } +} diff --git a/src/aspara/dashboard/static/js/chart/interaction-utils.js b/src/aspara/dashboard/static/js/chart/interaction-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..1d50d2997519a71f01e149b3f4c1e14c33841a98 --- /dev/null +++ b/src/aspara/dashboard/static/js/chart/interaction-utils.js @@ -0,0 +1,141 @@ +/** + * Pure utility functions for chart interaction + * These functions have no side effects and are easy to test + */ + +import { isLogScale, isValidLogValue } from './scale.js'; + +/** + * Calculate data ranges from series data (SoA format) + * @param {Array} series - Array of series objects with data in SoA format { steps: [], values: [] } + * @param {string} [scale='linear'] - Y-axis scale type ('linear' or 'log') + * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null if no data + */ +export function calculateDataRanges(series, scale = 'linear') { + let xMin = Number.POSITIVE_INFINITY; + let xMax = Number.NEGATIVE_INFINITY; + let yMin = Number.POSITIVE_INFINITY; + let yMax = Number.NEGATIVE_INFINITY; + + const logScale = isLogScale(scale); + + for (const s of series) { + if (!s.data?.steps?.length) continue; + const { steps, values } = s.data; + + // steps are sorted, so O(1) for min/max + xMin = Math.min(xMin, steps[0]); + xMax = Math.max(xMax, steps[steps.length - 1]); + + // values min/max + for (let i = 0; i < values.length; i++) { + if (logScale && !isValidLogValue(values[i])) continue; + if (values[i] < yMin) yMin = values[i]; + if (values[i] > yMax) yMax = values[i]; + } + } + + if (xMin === Number.POSITIVE_INFINITY || yMin === Number.POSITIVE_INFINITY) { + return null; + } + + return { xMin, xMax, yMin, yMax }; +} + +/** + * Binary search to find the nearest step in sorted steps array (SoA format) + * @param {Array} steps - Sorted array of step values + * @param {number} targetStep - Target step value + * @returns {Object|null} Object with { index, step } or null if empty + */ +export function binarySearchNearestStep(steps, targetStep) { + if (!steps?.length) return null; + if (steps.length === 1) return { index: 0, step: steps[0] }; + + let left = 0; + let right = steps.length - 1; + + // Handle edge cases: target is outside data range + if (targetStep <= steps[left]) return { index: left, step: steps[left] }; + if (targetStep >= steps[right]) return { index: right, step: steps[right] }; + + // Binary search to find the two closest points + while (left < right - 1) { + const mid = (left + right) >> 1; + if (steps[mid] <= targetStep) { + left = mid; + } else { + right = mid; + } + } + + // Compare left and right to find nearest + const leftDist = Math.abs(steps[left] - targetStep); + const rightDist = Math.abs(steps[right] - targetStep); + return leftDist <= rightDist ? { index: left, step: steps[left] } : { index: right, step: steps[right] }; +} + +/** + * Binary search to find a point by step value (SoA format) + * @param {Array} steps - Sorted array of step values + * @param {Array} values - Array of values corresponding to steps + * @param {number} step - Step value to find + * @returns {Object|null} Object with { index, step, value } or null if not found + */ +export function binarySearchByStep(steps, values, step) { + if (!steps?.length) return null; + + let left = 0; + let right = steps.length - 1; + + while (left <= right) { + const mid = (left + right) >> 1; + if (steps[mid] === step) { + return { index: mid, step: steps[mid], value: values[mid] }; + } + if (steps[mid] < step) { + left = mid + 1; + } else { + right = mid - 1; + } + } + + return null; +} + +/** + * Find the nearest step using binary search (SoA format, optimized version) + * Handles LTTB downsampling where each series may have different steps. + * @param {number} mouseX - Mouse X coordinate + * @param {Array} series - Series data in SoA format (may have different steps per series due to LTTB) + * @param {number} margin - Chart margin + * @param {number} plotWidth - Plot width + * @param {number} xMin - X axis minimum + * @param {number} xMax - X axis maximum + * @returns {number|null} Nearest step value or null + */ +export function findNearestStepBinary(mouseX, series, margin, plotWidth, xMin, xMax) { + // Convert mouse X to target step value + const targetStep = xMin + ((mouseX - margin) / plotWidth) * (xMax - xMin); + + let nearestStep = null; + let minDistance = Number.POSITIVE_INFINITY; + + // Search each series for the nearest step (handles LTTB where series have different steps) + // Complexity: O(N Ɨ log M) where N = series count, M = points per series + for (const s of series) { + if (!s.data?.steps?.length) continue; + + // Binary search to find nearest step in this series + const result = binarySearchNearestStep(s.data.steps, targetStep); + if (result === null) continue; + + const distance = Math.abs(result.step - targetStep); + if (distance < minDistance) { + minDistance = distance; + nearestStep = result.step; + } + } + + return nearestStep; +} diff --git a/src/aspara/dashboard/static/js/chart/interaction.js b/src/aspara/dashboard/static/js/chart/interaction.js new file mode 100644 index 0000000000000000000000000000000000000000..5604314c26b45b8d7a74b8be79e00399f04bcd4a --- /dev/null +++ b/src/aspara/dashboard/static/js/chart/interaction.js @@ -0,0 +1,433 @@ +import { removeEventListeners } from '../html-utils.js'; +import { binarySearchByStep, calculateDataRanges, findNearestStepBinary } from './interaction-utils.js'; +import { computePaddedYRange, fromLogDomain, isLogScale, isValidLogValue, toLogDomain, valueToChartY } from './scale.js'; + +export class ChartInteraction { + constructor(chart, renderer) { + this.chart = chart; + this.renderer = renderer; + // 2.4 Optimization: Cache data ranges to avoid recalculation on every mouse move + this._cachedRanges = null; + this._lastDataRef = null; + this._lastScale = null; + + // Canvas event handlers (stored for cleanup) + this.mousemoveHandler = null; + this.mouseleaveHandler = null; + this.mousedownHandler = null; + this.mouseupHandler = null; + this.dblclickHandler = null; + this.contextmenuHandler = null; + + // Throttling for mousemove draw calls + this._pendingMouseMoveDraw = false; + } + + /** + * Invalidate the cached data ranges. Call this when data or scale changes. + */ + invalidateRangesCache() { + this._cachedRanges = null; + this._lastDataRef = null; + this._lastScale = null; + } + + /** + * Get data ranges, using cache if data and scale haven't changed. + * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null + */ + _getDataRanges() { + // Check if data reference or scale changed (simple identity check) + if (this.chart.data?.series !== this._lastDataRef || this.chart.yScale !== this._lastScale) { + this._lastDataRef = this.chart.data?.series; + this._lastScale = this.chart.yScale; + this._cachedRanges = calculateDataRanges(this.chart.data?.series || [], this.chart.yScale); + } + return this._cachedRanges; + } + + setupEventListeners() { + // Remove any existing listeners first to prevent duplicates + this.removeEventListeners(); + + // Store handlers for cleanup + this.mousemoveHandler = (e) => this.handleMouseMove(e); + this.mouseleaveHandler = () => this.handleMouseLeave(); + this.mousedownHandler = (e) => this.handleMouseDown(e); + this.mouseupHandler = (e) => this.handleMouseUp(e); + this.dblclickHandler = () => this.chart.resetZoom(); + this.contextmenuHandler = (e) => e.preventDefault(); + + this.chart.canvas.addEventListener('mousemove', this.mousemoveHandler); + this.chart.canvas.addEventListener('mouseleave', this.mouseleaveHandler); + this.chart.canvas.addEventListener('mousedown', this.mousedownHandler); + this.chart.canvas.addEventListener('mouseup', this.mouseupHandler); + this.chart.canvas.addEventListener('dblclick', this.dblclickHandler); + this.chart.canvas.addEventListener('contextmenu', this.contextmenuHandler); + } + + /** + * Destroy the interaction and release all resources. + * Implements the Destroyable lifecycle contract. + */ + destroy() { + this.removeEventListeners(); + this._cachedRanges = null; + this._lastDataRef = null; + this._lastScale = null; + } + + /** + * Remove event listeners from canvas. + */ + removeEventListeners() { + removeEventListeners(this.chart.canvas, { + mousemove: this.mousemoveHandler, + mouseleave: this.mouseleaveHandler, + mousedown: this.mousedownHandler, + mouseup: this.mouseupHandler, + dblclick: this.dblclickHandler, + contextmenu: this.contextmenuHandler, + }); + this.mousemoveHandler = null; + this.mouseleaveHandler = null; + this.mousedownHandler = null; + this.mouseupHandler = null; + this.dblclickHandler = null; + this.contextmenuHandler = null; + } + + handleMouseMove(event) { + const rect = this.chart.canvas.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + // Cache zoomState reference to avoid repeated property access + const zoomState = this.chart.zoomState; + + if (zoomState.active) { + zoomState.currentX = mouseX; + zoomState.currentY = mouseY; + // Throttle draw calls during zoom selection using requestAnimationFrame + this._scheduleMouseMoveDraw(); + return; + } + + if (!this.chart.data || !this.chart.data.series || this.chart.data.series.length === 0) { + return; + } + + const nearestPoint = this.findNearestPoint(mouseX, mouseY); + + if (nearestPoint) { + this.chart.hoverPoint = nearestPoint; + this._scheduleMouseMoveDraw(); + } else if (this.chart.hoverPoint) { + this.chart.hoverPoint = null; + this._scheduleMouseMoveDraw(); + } + } + + /** + * Schedule a draw call using requestAnimationFrame to throttle mousemove updates. + */ + _scheduleMouseMoveDraw() { + if (this._pendingMouseMoveDraw) { + return; + } + this._pendingMouseMoveDraw = true; + requestAnimationFrame(() => { + this._pendingMouseMoveDraw = false; + this.chart.draw(); + }); + } + + handleMouseLeave() { + if (this.chart.hoverPoint) { + this.chart.hoverPoint = null; + this.chart.draw(); + } + } + + handleMouseDown(event) { + if (event.button !== 0) return; + + const rect = this.chart.canvas.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + const margin = this.chart.constructor.MARGIN; + const plotWidth = this.chart.width - margin * 2; + const plotHeight = this.chart.height - margin * 2; + + if (mouseX >= margin && mouseX <= margin + plotWidth && mouseY >= margin && mouseY <= margin + plotHeight) { + this.chart.zoomState.active = true; + this.chart.zoomState.startX = mouseX; + this.chart.zoomState.startY = mouseY; + this.chart.zoomState.currentX = mouseX; + this.chart.zoomState.currentY = mouseY; + } + } + + handleMouseUp(event) { + if (!this.chart.zoomState.active) return; + + this.chart.zoomState.active = false; + + const dragDistance = + Math.abs(this.chart.zoomState.currentX - this.chart.zoomState.startX) + Math.abs(this.chart.zoomState.currentY - this.chart.zoomState.startY); + + if (dragDistance < this.chart.constructor.MIN_DRAG_DISTANCE) { + this.chart.draw(); + return; + } + + this.applyZoom(); + } + + findNearestPoint(mouseX, mouseY) { + if (!this.chart.data || !this.chart.data.series || this.chart.data.series.length === 0) { + return null; + } + + const margin = this.chart.constructor.MARGIN; + const plotWidth = this.chart.width - margin * 2; + const plotHeight = this.chart.height - margin * 2; + + if (mouseX < margin || mouseX > margin + plotWidth || mouseY < margin || mouseY > margin + plotHeight) { + return null; + } + + // 2.4 Optimization: Use cached data ranges instead of recalculating on every mouse move + const ranges = this._getDataRanges(); + if (!ranges) return null; + + let { xMin, xMax, yMin, yMax } = ranges; + + // Apply zoom if active + if (this.chart.zoom.x) { + xMin = this.chart.zoom.x.min; + xMax = this.chart.zoom.x.max; + } + if (this.chart.zoom.y) { + yMin = this.chart.zoom.y.min; + yMax = this.chart.zoom.y.max; + } + + const { yMinPadded, yMaxPadded } = computePaddedYRange(yMin, yMax, this.chart.yScale, this.chart.constructor.Y_PADDING_RATIO); + + // Use binary search to find nearest step - O(log M) instead of O(NƗM) + const nearestStep = findNearestStepBinary(mouseX, this.chart.data.series, margin, plotWidth, xMin, xMax); + + if (nearestStep === null) return null; + + // Collect points at the nearest step using binary search - O(N Ɨ log M) + const nearestPoints = []; + for (const series of this.chart.data.series) { + if (!series.data?.steps?.length) continue; + const { steps, values } = series.data; + + const style = this.chart.colorPalette.getRunStyle(series.name); + + // Use binary search instead of linear .find() - O(log M) instead of O(M) + const result = binarySearchByStep(steps, values, nearestStep); + + if (result && (!isLogScale(this.chart.yScale) || isValidLogValue(result.value))) { + const x = margin + ((result.step - xMin) / (xMax - xMin)) * plotWidth; + const y = valueToChartY(result.value, this.chart.yScale, plotHeight, margin, yMinPadded, yMaxPadded); + + nearestPoints.push({ + data: { step: result.step, value: result.value }, + x: x, + y: y, + series: series.name, + color: style.borderColor, + }); + } + } + + return nearestPoints.length > 0 + ? { + points: nearestPoints, + step: nearestStep, + } + : null; + } + + drawHoverEffects() { + if (!this.chart.hoverPoint || !this.chart.hoverPoint.points) return; + + for (const point of this.chart.hoverPoint.points) { + this.chart.ctx.fillStyle = point.color; + this.chart.ctx.strokeStyle = 'white'; + this.chart.ctx.lineWidth = 2; + + this.chart.ctx.beginPath(); + this.chart.ctx.arc(point.x, point.y, 5, 0, 2 * Math.PI); + this.chart.ctx.fill(); + this.chart.ctx.stroke(); + } + + this.drawTooltip(); + } + + drawTooltip() { + if (!this.chart.hoverPoint || !this.chart.hoverPoint.points) return; + + const firstPoint = this.chart.hoverPoint.points[0]; + let tooltipX = firstPoint.x + 10; + let tooltipY = firstPoint.y - 10; + + const lineIndicatorLength = 16; + const lineIndicatorGap = 6; + const indicatorOffset = lineIndicatorLength + lineIndicatorGap; + + this.chart.ctx.font = '12px Arial'; + + const headerText = `Step: ${this.chart.hoverPoint.step}`; + let maxWidth = this.chart.ctx.measureText(headerText).width; + + for (const point of this.chart.hoverPoint.points) { + const seriesText = `${point.series}: ${point.data.value.toFixed(3)}`; + const textWidth = this.chart.ctx.measureText(seriesText).width; + maxWidth = Math.max(maxWidth, textWidth + indicatorOffset); + } + + const seriesLineHeight = 20; + const headerLineHeight = 18; + const paddingX = 12; + const paddingY = 10; + const tooltipWidth = maxWidth + paddingX * 2; + const tooltipHeight = paddingY + headerLineHeight + 8 + this.chart.hoverPoint.points.length * seriesLineHeight + paddingY; + + if (tooltipX + tooltipWidth > this.chart.width) { + tooltipX = firstPoint.x - tooltipWidth - 10; + } + if (tooltipY - tooltipHeight < 0) { + tooltipY = firstPoint.y + 20; + } + + this.renderer.drawRoundedRect(tooltipX, tooltipY - tooltipHeight, tooltipWidth, tooltipHeight, 6); + this.chart.ctx.fillStyle = 'rgba(75, 85, 99, 0.95)'; + this.chart.ctx.fill(); + + this.renderer.drawRoundedRect(tooltipX, tooltipY - tooltipHeight, tooltipWidth, tooltipHeight, 6); + this.chart.ctx.strokeStyle = 'rgba(229, 231, 235, 0.3)'; + this.chart.ctx.lineWidth = 1; + this.chart.ctx.stroke(); + + this.chart.ctx.fillStyle = 'white'; + this.chart.ctx.textAlign = 'left'; + this.chart.ctx.textBaseline = 'middle'; + const headerY = tooltipY - tooltipHeight + paddingY + headerLineHeight / 2; + this.chart.ctx.fillText(headerText, tooltipX + paddingX, headerY); + + for (let index = 0; index < this.chart.hoverPoint.points.length; index++) { + const point = this.chart.hoverPoint.points[index]; + const lineY = tooltipY - tooltipHeight + paddingY + headerLineHeight + 8 + (index + 0.5) * seriesLineHeight; + const indicatorY = lineY; + + const style = this.chart.colorPalette.getRunStyle(point.series); + + this.chart.ctx.strokeStyle = point.color; + this.chart.ctx.lineWidth = 2; + if (style.borderDash && style.borderDash.length > 0) { + this.chart.ctx.setLineDash(style.borderDash); + } else { + this.chart.ctx.setLineDash([]); + } + this.chart.ctx.beginPath(); + this.chart.ctx.moveTo(tooltipX + paddingX, indicatorY); + this.chart.ctx.lineTo(tooltipX + paddingX + lineIndicatorLength, indicatorY); + this.chart.ctx.stroke(); + this.chart.ctx.setLineDash([]); + + this.chart.ctx.fillStyle = 'white'; + const seriesText = `${point.series}: ${point.data.value.toFixed(3)}`; + this.chart.ctx.fillText(seriesText, tooltipX + paddingX + indicatorOffset, lineY); + } + } + + applyZoom() { + if (!this.chart.data || !this.chart.data.series || this.chart.data.series.length === 0) return; + + // Use cached data ranges instead of recalculating + const ranges = this._getDataRanges(); + if (!ranges) return; + + const { xMin: dataXMin, xMax: dataXMax, yMin: dataYMin, yMax: dataYMax } = ranges; + + const margin = 60; + const plotWidth = this.chart.width - margin * 2; + const plotHeight = this.chart.height - margin * 2; + + const zoomXMin = Math.min(this.chart.zoomState.startX, this.chart.zoomState.currentX); + const zoomXMax = Math.max(this.chart.zoomState.startX, this.chart.zoomState.currentX); + + const xMinRatio = (zoomXMin - margin) / plotWidth; + const xMaxRatio = (zoomXMax - margin) / plotWidth; + + const currentXMin = this.chart.zoom.x ? this.chart.zoom.x.min : dataXMin; + const currentXMax = this.chart.zoom.x ? this.chart.zoom.x.max : dataXMax; + const currentXRange = currentXMax - currentXMin; + + const newXMin = currentXMin + xMinRatio * currentXRange; + const newXMax = currentXMin + xMaxRatio * currentXRange; + + const zoomYMin = Math.min(this.chart.zoomState.startY, this.chart.zoomState.currentY); + const zoomYMax = Math.max(this.chart.zoomState.startY, this.chart.zoomState.currentY); + + const yMinRatio = (plotHeight - (zoomYMax - margin)) / plotHeight; + const yMaxRatio = (plotHeight - (zoomYMin - margin)) / plotHeight; + + const currentYMin = this.chart.zoom.y ? this.chart.zoom.y.min : dataYMin; + const currentYMax = this.chart.zoom.y ? this.chart.zoom.y.max : dataYMax; + + let newYMin; + let newYMax; + if (isLogScale(this.chart.yScale)) { + const logCurrentMin = toLogDomain(currentYMin); + const logCurrentMax = toLogDomain(currentYMax); + const logRange = logCurrentMax - logCurrentMin; + newYMin = fromLogDomain(logCurrentMin + yMinRatio * logRange); + newYMax = fromLogDomain(logCurrentMin + yMaxRatio * logRange); + } else { + const currentYRange = currentYMax - currentYMin; + newYMin = currentYMin + yMinRatio * currentYRange; + newYMax = currentYMin + yMaxRatio * currentYRange; + } + + this.chart.zoom.x = { min: newXMin, max: newXMax }; + this.chart.zoom.y = { min: newYMin, max: newYMax }; + + if (this.chart.onZoomChange) { + this.chart.onZoomChange({ x: this.chart.zoom.x, y: this.chart.zoom.y }); + } + + this.chart.draw(); + } + + drawZoomSelection() { + if (!this.chart.zoomState.active) return; + + const startX = this.chart.zoomState.startX; + const startY = this.chart.zoomState.startY; + const currentX = this.chart.zoomState.currentX; + const currentY = this.chart.zoomState.currentY; + + const x = Math.min(startX, currentX); + const y = Math.min(startY, currentY); + const width = Math.abs(currentX - startX); + const height = Math.abs(currentY - startY); + + this.chart.ctx.fillStyle = 'rgba(0, 123, 255, 0.1)'; + this.chart.ctx.fillRect(x, y, width, height); + + this.chart.ctx.strokeStyle = 'rgba(0, 123, 255, 0.5)'; + this.chart.ctx.lineWidth = 1; + this.chart.ctx.setLineDash([4, 4]); + this.chart.ctx.strokeRect(x, y, width, height); + + this.chart.ctx.setLineDash([]); + } +} diff --git a/src/aspara/dashboard/static/js/chart/renderer.js b/src/aspara/dashboard/static/js/chart/renderer.js new file mode 100644 index 0000000000000000000000000000000000000000..783d3bc0e4b38fe89b80fd195ee6c9920418b533 --- /dev/null +++ b/src/aspara/dashboard/static/js/chart/renderer.js @@ -0,0 +1,173 @@ +import { YScale, generateLogTicks, isLogScale, valueToChartY } from './scale.js'; + +export class ChartRenderer { + constructor(chart) { + this.chart = chart; + } + + drawGrid(margin, plotWidth, plotHeight, xMin, xMax, yMin, yMax, yScale = YScale.LINEAR) { + this.chart.ctx.strokeStyle = '#e0e0e0'; + this.chart.ctx.lineWidth = this.chart.constructor.GRID_LINE_WIDTH; + + const xGridCount = this.chart.constructor.X_GRID_COUNT; + for (let i = 0; i <= xGridCount; i++) { + const x = margin + (i / xGridCount) * plotWidth; + this.chart.ctx.beginPath(); + this.chart.ctx.moveTo(x, margin); + this.chart.ctx.lineTo(x, margin + plotHeight); + this.chart.ctx.stroke(); + } + + if (isLogScale(yScale)) { + const ticks = generateLogTicks(yMin, yMax, this.chart.constructor.Y_GRID_COUNT); + for (const { value } of ticks) { + const y = valueToChartY(value, yScale, plotHeight, margin, yMin, yMax); + this.chart.ctx.beginPath(); + this.chart.ctx.moveTo(margin, y); + this.chart.ctx.lineTo(margin + plotWidth, y); + this.chart.ctx.stroke(); + } + } else { + const yGridCount = this.chart.constructor.Y_GRID_COUNT; + for (let i = 0; i <= yGridCount; i++) { + const y = margin + (i / yGridCount) * plotHeight; + this.chart.ctx.beginPath(); + this.chart.ctx.moveTo(margin, y); + this.chart.ctx.lineTo(margin + plotWidth, y); + this.chart.ctx.stroke(); + } + } + } + + drawAxisLabels(margin, plotWidth, plotHeight, xMin, xMax, yMin, yMax, yScale = YScale.LINEAR) { + this.chart.ctx.fillStyle = '#666'; + this.chart.ctx.font = '11px Arial'; + this.chart.ctx.textAlign = 'center'; + + const xGridCount = this.chart.constructor.X_GRID_COUNT; + for (let i = 0; i <= xGridCount; i++) { + const value = xMin + (i / xGridCount) * (xMax - xMin); + const x = margin + (i / xGridCount) * plotWidth; + const y = margin + plotHeight + 15; + + if (i % 2 === 0) { + let label; + if (value >= 1000) { + label = `${Math.round(value / 1000)}k`; + } else { + label = Math.round(value).toString(); + } + + this.chart.ctx.fillText(label, x, y); + } + } + + this.chart.ctx.textAlign = 'right'; + if (isLogScale(yScale)) { + const ticks = generateLogTicks(yMin, yMax, this.chart.constructor.Y_GRID_COUNT); + for (const { value, label } of ticks) { + const y = valueToChartY(value, yScale, plotHeight, margin, yMin, yMax); + this.chart.ctx.fillText(label, margin - 8, y + 4); + } + } else { + const yGridCount = this.chart.constructor.Y_GRID_COUNT; + for (let i = 0; i <= yGridCount; i++) { + const value = yMax - (i / yGridCount) * (yMax - yMin); + const x = margin - 8; + const y = margin + (i / yGridCount) * plotHeight + 4; + + if (i % 2 === 0) { + let label; + if (Math.abs(value) >= 1) { + label = value.toFixed(1); + } else { + label = value.toFixed(3); + } + + this.chart.ctx.fillText(label, x, y); + } + } + } + } + + /** + * Draw a centered message on the canvas (e.g. when log scale cannot be used). + * @param {string} message + */ + drawMessage(message) { + const ctx = this.chart.ctx; + ctx.save(); + ctx.fillStyle = '#666'; + ctx.font = '14px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(message, this.chart.width / 2, this.chart.height / 2); + ctx.restore(); + } + + drawLegend() { + if (!this.chart.data || !this.chart.data.series || this.chart.data.series.length <= 1) { + return; + } + + const itemSpacing = this.chart.constructor.LEGEND_ITEM_SPACING; + const lineLength = this.chart.constructor.LEGEND_LINE_LENGTH; + const textOffset = this.chart.constructor.LEGEND_TEXT_OFFSET; + + this.chart.ctx.font = '11px Arial'; + + const items = this.chart.data.series.map((series) => { + const style = this.chart.colorPalette.getRunStyle(series.name); + const textWidth = this.chart.ctx.measureText(series.name).width; + return { + name: series.name, + style: style, + width: lineLength + textOffset + textWidth, + }; + }); + + const totalWidth = items.reduce((sum, item, i) => sum + item.width + (i < items.length - 1 ? itemSpacing : 0), 0); + + const legendY = this.chart.height - this.chart.constructor.LEGEND_Y_OFFSET; + let currentX = (this.chart.width - totalWidth) / 2; + + // Cache context reference to avoid repeated property access in loop + const ctx = this.chart.ctx; + + for (const item of items) { + ctx.strokeStyle = item.style.borderColor; + ctx.lineWidth = 2; + if (item.style.borderDash && item.style.borderDash.length > 0) { + ctx.setLineDash(item.style.borderDash); + } else { + ctx.setLineDash([]); + } + ctx.beginPath(); + ctx.moveTo(currentX, legendY); + ctx.lineTo(currentX + lineLength, legendY); + ctx.stroke(); + ctx.setLineDash([]); + + ctx.fillStyle = '#374151'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(item.name, currentX + lineLength + textOffset, legendY); + + currentX += item.width + itemSpacing; + } + } + + drawRoundedRect(x, y, width, height, radius) { + this.chart.ctx.beginPath(); + this.chart.ctx.moveTo(x + radius, y); + this.chart.ctx.lineTo(x + width - radius, y); + this.chart.ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + this.chart.ctx.lineTo(x + width, y + height - radius); + this.chart.ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + this.chart.ctx.lineTo(x + radius, y + height); + this.chart.ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + this.chart.ctx.lineTo(x, y + radius); + this.chart.ctx.quadraticCurveTo(x, y, x + radius, y); + this.chart.ctx.closePath(); + } +} diff --git a/src/aspara/dashboard/static/js/chart/scale.js b/src/aspara/dashboard/static/js/chart/scale.js new file mode 100644 index 0000000000000000000000000000000000000000..962ed0aead1dcaa66a04f896dbc4cccc99cb9920 --- /dev/null +++ b/src/aspara/dashboard/static/js/chart/scale.js @@ -0,0 +1,190 @@ +/** + * Y-axis scaling utilities for the canvas chart. + * + * Centralizes mapping between data values and canvas coordinates for + * linear and logarithmic scales so that rendering, interaction, and zoom + * all use the same transformation (SSOT). + */ + +export const YScale = Object.freeze({ + LINEAR: 'linear', + LOG: 'log', +}); + +const LOG_10 = Math.log(10); + +/** + * Check whether a scale is logarithmic. + * @param {string} scale + * @returns {boolean} + */ +export function isLogScale(scale) { + return scale === YScale.LOG; +} + +/** + * Values that can be plotted on a true log scale must be strictly positive. + * @param {number} value + * @returns {boolean} + */ +export function isValidLogValue(value) { + return Number.isFinite(value) && value > 0; +} + +/** + * Convert a data value to its base-10 logarithm. + * @param {number} value + * @returns {number} + */ +export function toLogDomain(value) { + return Math.log(value) / LOG_10; +} + +/** + * Convert a base-10 logarithm back to the data domain. + * @param {number} logValue + * @returns {number} + */ +export function fromLogDomain(logValue) { + return 10 ** logValue; +} + +/** + * Compute the padded y-axis range used for drawing. + * + * For linear scales padding is applied in data space. For log scales padding + * is applied in log space so that the visual margin stays proportional on + * a logarithmic axis. + * + * @param {number} yMin - Data-space minimum + * @param {number} yMax - Data-space maximum + * @param {string} scale - YScale value + * @param {number} paddingRatio - Fraction of the range to pad + * @returns {{ yMinPadded: number, yMaxPadded: number }} + */ +export function computePaddedYRange(yMin, yMax, scale, paddingRatio) { + if (isLogScale(scale)) { + if (yMin <= 0 || yMax <= 0) { + throw new Error('Log scale requires positive yMin and yMax'); + } + let logMin = toLogDomain(yMin); + let logMax = toLogDomain(yMax); + let logRange = logMax - logMin; + if (logRange === 0) { + // All values are identical; give a default 1-decade range so the line + // still renders horizontally in the middle of the plot. + logRange = 1; + logMin -= 0.5; + logMax += 0.5; + } + const paddedLogMin = logMin - logRange * paddingRatio; + const paddedLogMax = logMax + logRange * paddingRatio; + return { + yMinPadded: fromLogDomain(paddedLogMin), + yMaxPadded: fromLogDomain(paddedLogMax), + }; + } + + const yRange = yMax - yMin; + if (yRange === 0) { + // Avoid degenerate zero-range axes. + return { yMinPadded: yMin - 1, yMaxPadded: yMax + 1 }; + } + return { + yMinPadded: yMin - yRange * paddingRatio, + yMaxPadded: yMax + yRange * paddingRatio, + }; +} + +/** + * Map a data value to a canvas y-coordinate. + * + * @param {number} value + * @param {string} scale - YScale value + * @param {number} plotHeight + * @param {number} margin + * @param {number} yMinPadded + * @param {number} yMaxPadded + * @returns {number} + */ +export function valueToChartY(value, scale, plotHeight, margin, yMinPadded, yMaxPadded) { + if (isLogScale(scale)) { + const logValue = toLogDomain(value); + const logMin = toLogDomain(yMinPadded); + const logMax = toLogDomain(yMaxPadded); + const ratio = (logValue - logMin) / (logMax - logMin); + return margin + plotHeight - ratio * plotHeight; + } + const ratio = (value - yMinPadded) / (yMaxPadded - yMinPadded); + return margin + plotHeight - ratio * plotHeight; +} + +/** + * Map a canvas y-coordinate back to a data value. + * + * @param {number} y + * @param {string} scale - YScale value + * @param {number} plotHeight + * @param {number} margin + * @param {number} yMinPadded + * @param {number} yMaxPadded + * @returns {number} + */ +export function chartYToValue(y, scale, plotHeight, margin, yMinPadded, yMaxPadded) { + const ratio = (plotHeight - (y - margin)) / plotHeight; + if (isLogScale(scale)) { + const logMin = toLogDomain(yMinPadded); + const logMax = toLogDomain(yMaxPadded); + return fromLogDomain(logMin + ratio * (logMax - logMin)); + } + return yMinPadded + ratio * (yMaxPadded - yMinPadded); +} + +/** + * Format a tick value for a logarithmic axis. + * + * Uses plain decimal notation for values from 0.01 up to large integers, and + * exponential notation for very small values, matching the wandb/TensorBoard + * convention for readability. + * + * @param {number} value + * @returns {string} + */ +export function formatLogTick(value) { + const abs = Math.abs(value); + if (abs === 0) return '0'; + if (abs >= 0.01) return value.toString(); + return value.toExponential(1).replace(/\.0/, ''); +} + +/** + * Generate major tick positions for a logarithmic axis. + * + * Ticks are placed at powers of ten within the requested range. If no full + * power of ten falls inside the range, the endpoints are used as a fallback. + * + * @param {number} yMin + * @param {number} yMax + * @param {number} [maxTicks=10] + * @returns {Array<{ value: number, label: string }>} + */ +export function generateLogTicks(yMin, yMax, maxTicks = 10) { + const logMin = Math.ceil(toLogDomain(yMin)); + const logMax = Math.floor(toLogDomain(yMax)); + const ticks = []; + for (let exponent = logMin; exponent <= logMax; exponent++) { + const value = fromLogDomain(exponent); + ticks.push({ value, label: formatLogTick(value) }); + } + if (ticks.length === 0) { + ticks.push( + { value: yMin, label: formatLogTick(yMin) }, + { value: yMax, label: formatLogTick(yMax) }, + ); + } + if (ticks.length > maxTicks) { + const step = Math.ceil(ticks.length / maxTicks); + return ticks.filter((_, index) => index % step === 0); + } + return ticks; +} diff --git a/src/aspara/dashboard/static/js/components/delete-dialog.js b/src/aspara/dashboard/static/js/components/delete-dialog.js new file mode 100644 index 0000000000000000000000000000000000000000..ba2a26f9fef41a94a1a5f03dbd1b0cc81cfe25c4 --- /dev/null +++ b/src/aspara/dashboard/static/js/components/delete-dialog.js @@ -0,0 +1,97 @@ +/** + * Delete confirmation dialog controller + * Uses native with Invoker Commands API for open/close + * Only JS needed: dynamic content update and delete API calls + */ +import { guardReadOnly } from '../read-only-guard.js'; + +class DeleteDialog { + constructor() { + this.dialog = document.getElementById('delete-confirm-dialog'); + this.titleEl = document.getElementById('delete-dialog-title'); + this.messageEl = document.getElementById('delete-dialog-message'); + this.pendingCallback = null; + + // Event handlers (stored for cleanup) + this.dialogCloseHandler = null; + this.documentClickHandler = null; + + this.init(); + } + + init() { + if (!this.dialog) return; + + // Handle dialog close event (stored for cleanup) + this.dialogCloseHandler = () => { + if (this.pendingCallback) { + const confirmed = this.dialog.returnValue === 'confirm'; + this.pendingCallback(confirmed); + this.pendingCallback = null; + } + }; + this.dialog.addEventListener('close', this.dialogCloseHandler); + + // Intercept delete button clicks to set dialog content (stored for cleanup) + this.documentClickHandler = (e) => { + const btn = e.target.closest('[commandfor="delete-confirm-dialog"]'); + if (!btn) return; + + if (guardReadOnly()) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + const project = btn.dataset.project; + const run = btn.dataset.run; + + if (run) { + this.titleEl.textContent = 'Delete Run'; + this.messageEl.textContent = `Are you sure you want to delete run "${run}"?\nThis action cannot be undone.`; + } else if (project) { + this.titleEl.textContent = 'Delete Project'; + this.messageEl.textContent = `Are you sure you want to delete project "${project}"?\nThis action cannot be undone.`; + } + }; + document.addEventListener('click', this.documentClickHandler); + } + + /** + * Programmatically show confirmation dialog + * @param {Object} options + * @param {string} options.title - Dialog title + * @param {string} options.message - Dialog message + * @returns {Promise} Resolves to true if confirmed + */ + confirm(options) { + if (guardReadOnly()) return Promise.resolve(false); + this.titleEl.textContent = options.title; + this.messageEl.textContent = options.message; + this.dialog.showModal(); + return new Promise((resolve) => { + this.pendingCallback = resolve; + }); + } + + /** + * Clean up event listeners. + */ + destroy() { + if (this.dialog && this.dialogCloseHandler) { + this.dialog.removeEventListener('close', this.dialogCloseHandler); + this.dialogCloseHandler = null; + } + if (this.documentClickHandler) { + document.removeEventListener('click', this.documentClickHandler); + this.documentClickHandler = null; + } + } +} + +const deleteDialog = new DeleteDialog(); + +// Expose for programmatic use and backward compatibility +window.showConfirm = (opts) => deleteDialog.confirm(opts); + +export { deleteDialog, DeleteDialog }; diff --git a/src/aspara/dashboard/static/js/components/run-selector.js b/src/aspara/dashboard/static/js/components/run-selector.js new file mode 100644 index 0000000000000000000000000000000000000000..cec4a1185023ca27d7d03f46fc2bc36cf96895e3 --- /dev/null +++ b/src/aspara/dashboard/static/js/components/run-selector.js @@ -0,0 +1,312 @@ +/** + * Run selector component. + * Handles run selection, filtering, and sorting UI in the project detail page. + */ + +import { debounce } from '../timer-utils.js'; + +/** + * RunSelector manages the run selection UI including checkboxes, filtering, and sorting. + */ +export class RunSelector { + /** + * @param {Object} options - Configuration options + * @param {function} options.onSelectionChange - Callback when selection changes + */ + constructor(options = {}) { + this.selectedRuns = new Set(); + this.manuallyDeselectedRuns = new Set(); + this.allRuns = []; + this.sortKey = localStorage.getItem('project_runs_sort_key') || 'name'; + this.sortOrder = localStorage.getItem('project_runs_sort_order') || 'asc'; + this.onSelectionChange = options.onSelectionChange || null; + this.initialLoadComplete = false; + + // Stored handlers for cleanup + this._filterHandler = null; + this._filterDebounced = null; + this._checkboxHandlers = []; + this._sortButtonHandlers = []; + + this.initializeElements(); + this.setupEventListeners(); + this.loadInitialRuns(); + } + + /** + * Initialize DOM element references. + */ + initializeElements() { + this.filterInput = document.getElementById('runFilter'); + this.selectedCountSpan = document.getElementById('selectedCount'); + this.runCheckboxes = document.querySelectorAll('.run-checkbox'); + this.collapsedSelectedCount = document.getElementById('collapsed-selected-count'); + } + + /** + * Setup event listeners for run selection UI. + */ + setupEventListeners() { + if (this.filterInput) { + // Debounce filter re-rendering to avoid reflowing the entire run list + // on every keystroke (important for large run lists). Shares the + // common debounce timing via timer-utils (SSOT). + this._filterDebounced = debounce((value) => this.filterRuns(value)); + this._filterHandler = (e) => { + this._filterDebounced(e.target.value); + }; + this.filterInput.addEventListener('input', this._filterHandler); + } + + for (const checkbox of this.runCheckboxes) { + const handler = (e) => { + this.handleRunSelection(e.target); + }; + checkbox.addEventListener('change', handler); + this._checkboxHandlers.push({ element: checkbox, handler }); + } + + const sortButtons = document.querySelectorAll('[data-sort-runs]'); + for (const button of sortButtons) { + const handler = () => { + const key = button.dataset.sortRuns; + if (this.sortKey === key) { + this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; + } else { + this.sortKey = key; + this.sortOrder = 'asc'; + } + localStorage.setItem('project_runs_sort_key', this.sortKey); + localStorage.setItem('project_runs_sort_order', this.sortOrder); + this.updateSortIndicators(); + this.sortRuns(); + }; + button.addEventListener('click', handler); + this._sortButtonHandlers.push({ element: button, handler }); + } + + this.updateSortIndicators(); + } + + /** + * Load initial runs from checkboxes. + */ + loadInitialRuns() { + for (const checkbox of this.runCheckboxes) { + const runName = checkbox.dataset.runName; + this.allRuns.push(runName); + this.selectedRuns.add(runName); + checkbox.checked = true; + } + + this.updateSelectedCount(); + this.sortRuns(); + this.initialLoadComplete = true; + } + + /** + * Update sort indicators in UI. + */ + updateSortIndicators() { + const sortButtons = document.querySelectorAll('[data-sort-runs]'); + for (const button of sortButtons) { + const indicator = button.querySelector('.sort-runs-indicator'); + if (!indicator) continue; + + if (button.dataset.sortRuns === this.sortKey) { + indicator.textContent = this.sortOrder === 'asc' ? '↑' : '↓'; + indicator.classList.remove('text-text-muted'); + indicator.classList.add('text-text-primary'); + } else { + indicator.textContent = '↕'; + indicator.classList.remove('text-text-primary'); + indicator.classList.add('text-text-muted'); + } + } + } + + /** + * Sort runs in the UI. + */ + sortRuns() { + const container = document.getElementById('runs-list-container'); + if (!container) return; + + const runItems = Array.from(container.querySelectorAll('.run-item')); + + runItems.sort((a, b) => { + let aVal; + let bVal; + + switch (this.sortKey) { + case 'name': + aVal = a.dataset.runName.toLowerCase(); + bVal = b.dataset.runName.toLowerCase(); + break; + case 'lastUpdate': + aVal = Number.parseInt(a.dataset.runLastUpdate) || 0; + bVal = Number.parseInt(b.dataset.runLastUpdate) || 0; + break; + default: + return 0; + } + + if (aVal < bVal) return this.sortOrder === 'asc' ? -1 : 1; + if (aVal > bVal) return this.sortOrder === 'asc' ? 1 : -1; + return 0; + }); + + for (const item of runItems) { + container.appendChild(item); + } + } + + /** + * Filter runs by regex pattern. + * @param {string} pattern - Regex pattern to filter runs + */ + filterRuns(pattern) { + const input = this.filterInput; + + try { + const regex = new RegExp(pattern, 'i'); + + // Valid regex - remove error state + input?.classList.remove('border-status-error'); + + for (const checkbox of this.runCheckboxes) { + const runName = checkbox.dataset.runName; + const runItem = checkbox.closest('.run-item'); + const matches = !pattern || regex.test(runName); + + runItem.style.display = matches ? '' : 'none'; + if (matches && !this.manuallyDeselectedRuns.has(runName)) { + checkbox.checked = true; + this.selectedRuns.add(runName); + } + } + + this.updateSelectedCount(); + } catch (e) { + // Invalid regex - add error state and show all runs + input?.classList.add('border-status-error'); + + for (const checkbox of this.runCheckboxes) { + const runItem = checkbox.closest('.run-item'); + runItem.style.display = ''; + } + } + } + + /** + * Handle checkbox change event. + * @param {HTMLInputElement} checkbox - The changed checkbox element + */ + handleRunSelection(checkbox) { + const runName = checkbox.dataset.runName; + + if (checkbox.checked) { + this.selectedRuns.add(runName); + this.manuallyDeselectedRuns.delete(runName); + } else { + this.selectedRuns.delete(runName); + this.manuallyDeselectedRuns.add(runName); + } + + this.updateSelectedCount(); + + if (this.initialLoadComplete && this.onSelectionChange) { + this.onSelectionChange(this.selectedRuns); + } + } + + /** + * Update the selected count display. + */ + updateSelectedCount() { + if (this.selectedCountSpan) { + this.selectedCountSpan.textContent = this.selectedRuns.size; + } + if (this.collapsedSelectedCount) { + this.collapsedSelectedCount.textContent = this.selectedRuns.size; + } + } + + /** + * Get the set of selected runs. + * @returns {Set} + */ + getSelectedRuns() { + return this.selectedRuns; + } + + /** + * Update run color legends in the sidebar. + * @param {function} getRunStyle - Function to get style for a run + */ + updateRunColorLegends(getRunStyle) { + for (const runName of this.selectedRuns) { + const legendElement = document.querySelector(`[data-run-legend="${runName}"]`); + if (!legendElement) continue; + + const style = getRunStyle(runName); + if (!style) continue; + + const lineElement = legendElement.querySelector('line'); + if (lineElement) { + lineElement.setAttribute('stroke', style.borderColor); + + if (style.borderDash && style.borderDash.length > 0) { + lineElement.setAttribute('stroke-dasharray', style.borderDash.join(' ')); + } else { + lineElement.removeAttribute('stroke-dasharray'); + } + } + + legendElement.style.display = 'block'; + } + + // Hide legends for unselected runs + for (const checkbox of this.runCheckboxes) { + const runName = checkbox.dataset.runName; + if (!this.selectedRuns.has(runName)) { + const legendElement = document.querySelector(`[data-run-legend="${runName}"]`); + if (legendElement) { + legendElement.style.display = 'none'; + } + } + } + } + + /** + * Hide all color legends. + */ + hideAllLegends() { + const allLegends = document.querySelectorAll('[data-run-legend]'); + for (const legend of allLegends) { + legend.style.display = 'none'; + } + } + + /** + * Clean up event listeners. + */ + destroy() { + if (this._filterDebounced) { + this._filterDebounced.cancel(); + this._filterDebounced = null; + } + if (this.filterInput && this._filterHandler) { + this.filterInput.removeEventListener('input', this._filterHandler); + this._filterHandler = null; + } + for (const { element, handler } of this._checkboxHandlers) { + element.removeEventListener('change', handler); + } + this._checkboxHandlers = []; + for (const { element, handler } of this._sortButtonHandlers) { + element.removeEventListener('click', handler); + } + this._sortButtonHandlers = []; + } +} diff --git a/src/aspara/dashboard/static/js/components/sse-status-indicator.js b/src/aspara/dashboard/static/js/components/sse-status-indicator.js new file mode 100644 index 0000000000000000000000000000000000000000..4d3846f87ccaad001b4752787fab9356a6ef0e32 --- /dev/null +++ b/src/aspara/dashboard/static/js/components/sse-status-indicator.js @@ -0,0 +1,154 @@ +/** + * SSE Status Indicator + * + * Displays the current SSE connection state (connected / reconnecting / disconnected) + * so users can tell whether the data they are viewing is live or stale. + */ + +/** @typedef {'connected' | 'reconnecting' | 'disconnected'} ConnectionState */ + +/** + * Format a timestamp (ms) as a relative "x ago" string. + * @param {number} timestamp - UNIX timestamp in milliseconds + * @returns {string} Human-readable relative time + */ +export function formatRelativeTime(timestamp) { + if (!timestamp || timestamp <= 0) return ''; + const diff = Date.now() - timestamp; + if (diff < 5000) return 'just now'; + if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + return `${Math.floor(diff / 3600000)}h ago`; +} + +/** + * Build the inner HTML for a given connection state. + * Pure function — no DOM side effects. + * @param {ConnectionState} state + * @param {Object} [opts] + * @param {number} [opts.reconnectAttempt] + * @param {number} [opts.maxReconnectAttempts] + * @param {number} [opts.lastEventTime] - UNIX ms of last received event + * @returns {string} HTML string + */ +export function buildStatusHTML(state, opts = {}) { + switch (state) { + case 'connected': + return `Live`; + case 'reconnecting': { + const attempt = opts.reconnectAttempt ?? 0; + const max = opts.maxReconnectAttempts ?? 0; + const label = max > 0 ? `Reconnecting (${attempt}/${max})…` : 'Reconnecting…'; + return `${label}`; + } + case 'disconnected': { + const last = formatRelativeTime(opts.lastEventTime ?? 0); + const lastStr = last ? ` Ā· Last updated ${last}` : ''; + return `Disconnected${lastStr}`; + } + default: + return ''; + } +} + +/** + * SSEStatusIndicator manages a DOM element that reflects the SSE connection state. + * + * The indicator is keyboard-accessible (role="status", aria-live="polite") and + * uses both colour and text/shape to distinguish states. + */ +export class SSEStatusIndicator { + /** + * @param {string|HTMLElement} elementOrId - DOM element or its ID + */ + constructor(elementOrId) { + if (typeof elementOrId === 'string') { + this.element = document.getElementById(elementOrId); + } else { + this.element = elementOrId; + } + + if (this.element) { + this.element.setAttribute('role', 'status'); + this.element.setAttribute('aria-live', 'polite'); + this.element.classList.add('sse-status'); + } + + /** @type {ConnectionState|null} */ + this.currentState = null; + this.reconnectAttempt = 0; + this.maxReconnectAttempts = 0; + this.lastEventTime = 0; + } + + /** + * Set the indicator to the "connected" state. + */ + setConnected() { + this.currentState = 'connected'; + this._render(); + } + + /** + * Set the indicator to the "reconnecting" state. + * @param {number} attempt - Current reconnect attempt number + * @param {number} max - Maximum reconnect attempts + */ + setReconnecting(attempt, max) { + this.currentState = 'reconnecting'; + this.reconnectAttempt = attempt; + this.maxReconnectAttempts = max; + this._render(); + } + + /** + * Set the indicator to the "disconnected" state. + * @param {number} lastEventTime - UNIX ms of the last received event + */ + setDisconnected(lastEventTime) { + this.currentState = 'disconnected'; + this.lastEventTime = lastEventTime; + this._render(); + } + + /** + * Record that an event was received (updates lastEventTime). + * If currently disconnected, transitions back to connected. + * @param {number} [timestamp] - UNIX ms; defaults to Date.now() + */ + recordEvent(timestamp) { + this.lastEventTime = timestamp ?? Date.now(); + if (this.currentState === 'disconnected') { + this.setConnected(); + } + } + + /** + * Hide the indicator (for pages that don't use SSE). + */ + hide() { + if (this.element) { + this.element.classList.add('hidden'); + } + } + + /** + * Show the indicator. + */ + show() { + if (this.element) { + this.element.classList.remove('hidden'); + } + } + + _render() { + if (!this.element) return; + this.show(); + const html = buildStatusHTML(this.currentState, { + reconnectAttempt: this.reconnectAttempt, + maxReconnectAttempts: this.maxReconnectAttempts, + lastEventTime: this.lastEventTime, + }); + this.element.innerHTML = html; + } +} diff --git a/src/aspara/dashboard/static/js/dev-mode.js b/src/aspara/dashboard/static/js/dev-mode.js new file mode 100644 index 0000000000000000000000000000000000000000..b4a9591e866d109787ca0e95d074da795217869c --- /dev/null +++ b/src/aspara/dashboard/static/js/dev-mode.js @@ -0,0 +1,11 @@ +/** + * Development mode utilities + */ + +/** + * Check if running in development mode. + * @returns {boolean} true if dev mode is active + */ +export function isDev() { + return document.body.hasAttribute('data-dev-mode'); +} diff --git a/src/aspara/dashboard/static/js/html-utils.js b/src/aspara/dashboard/static/js/html-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..d5ba1ac3f6695246981188eaaa905f256ace05ac --- /dev/null +++ b/src/aspara/dashboard/static/js/html-utils.js @@ -0,0 +1,56 @@ +/** + * Common utility functions for the dashboard + */ + +/** + * Icon SVG constants using symbol references + * These reference symbols defined in _icons.mustache (generated by build:icons) + */ +export const ICON_EDIT = ``; +export const ICON_RESET_ZOOM = ``; +export const ICON_FULLSCREEN = ``; +export const ICON_DOWNLOAD = ``; +export const ICON_HELP = ``; + +/** + * Accessible labels for chart control buttons (SSOT). + * Used as both the `aria-label` and the `title` attribute so the + * tooltip and the screen-reader name stay in sync. + */ +export const CHART_CONTROL_LABELS = Object.freeze({ + resetZoom: 'Reset zoom', + enterFullscreen: 'Enter fullscreen', + exitFullscreen: 'Exit fullscreen', + download: 'Download data', + help: 'Chart interactions help', + toggleLogScale: 'Toggle log scale', +}); + +/** + * Escape HTML special characters to prevent XSS attacks + * @param {string} text - Text to escape + * @returns {string} Escaped HTML string + */ +export function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * Remove a set of event listeners from a target in one call. + * + * Guards against null targets and null handlers so callers can pass the + * current handler map without extra if-checks. + * + * @param {EventTarget|null} target - Event target to remove listeners from + * @param {Object} handlers - Map of event names to handlers + */ +export function removeEventListeners(target, handlers) { + if (!target) return; + for (const [event, handler] of Object.entries(handlers)) { + if (handler) { + target.removeEventListener(event, handler); + } + } +} diff --git a/src/aspara/dashboard/static/js/lifecycle.js b/src/aspara/dashboard/static/js/lifecycle.js new file mode 100644 index 0000000000000000000000000000000000000000..e2595b19c1f30eedca5115937bf1f98bb4fd68a0 --- /dev/null +++ b/src/aspara/dashboard/static/js/lifecycle.js @@ -0,0 +1,49 @@ +/** + * @file Lifecycle contract for all resource-holding frontend classes. + * + * Every class that owns long-lived resources (SSE connections, event + * listeners, timers, child instances, etc.) must implement `destroy()`. + * + * The contract: + * - `destroy()` MUST be idempotent (safe to call multiple times). + * - `destroy()` MUST remove all event listeners added by the class. + * - `destroy()` MUST close/stop any async resources (SSE, timers, etc.). + * - `destroy()` MUST call `destroy()` on owned child instances. + * - After `destroy()`, the instance should not be used again. + * + * Pure utility modules and stateless functions do not need to implement + * this interface. + */ + +/** + * @typedef {Object} Destroyable + * @property {function(): void} destroy - Release all resources held by this instance. Must be idempotent. + */ + +/** + * Register a pagehide listener that tears down a page on true unload + * but preserves it when frozen into the back/forward cache (bfcache). + * + * When the browser freezes a page into bfcache (back/forward navigation), + * `pagehide` fires with `event.persisted === true`. In that case the page + * must keep its event listeners and resources intact so it works + * immediately when restored from bfcache. Only call `destroy()` on a + * real unload (`persisted === false`). + * + * Using `beforeunload` instead would call `destroy()` unconditionally, + * stripping listeners before bfcache freezes the page — so after + * restoration the page is inert (e.g. clicking a project card does + * nothing). `pagehide` + `persisted` is the correct hook. + * + * @param {Destroyable} page - Page instance whose destroy() releases resources. + * @returns {function(): void} Cleanup function to remove the listener (for tests). + */ +export function registerPageLifecycle(page) { + const handler = (event) => { + if (!event.persisted) { + page.destroy(); + } + }; + window.addEventListener('pagehide', handler); + return () => window.removeEventListener('pagehide', handler); +} diff --git a/src/aspara/dashboard/static/js/metrics/chart-layout.js b/src/aspara/dashboard/static/js/metrics/chart-layout.js new file mode 100644 index 0000000000000000000000000000000000000000..07842e0cc1c3d78d6e51ca263beae969715b0f53 --- /dev/null +++ b/src/aspara/dashboard/static/js/metrics/chart-layout.js @@ -0,0 +1,117 @@ +/** + * Shared chart layout utilities. + * Provides common layout calculation and grid management for chart pages. + */ + +/** Chart base width map for S/M/L sizes. + * Single source of truth for the per-size target width. The + * _chart_size_controls.mustache template references these values in its + * button `title` attributes — keep them in sync. */ +export const CHART_BASE_WIDTH_MAP = { + S: 300, + M: 400, + L: 600, +}; + +/** Chart container padding map for S/M/L sizes (corresponds to Tailwind p-3/p-6) */ +export const CHART_PADDING_MAP = { + S: 12, // p-3 = 12px + M: 24, // p-6 = 24px + L: 24, // p-6 = 24px +}; + +/** LocalStorage key for chart size preference */ +export const CHART_SIZE_KEY = 'chart_size'; + +/** + * Calculate chart dimensions based on container width and chart size. + * + * @param {number} containerWidth - Width of the container in pixels + * @param {string} chartSize - Chart size ('S', 'M', or 'L') + * @param {number} [gap=32] - Gap between charts in pixels + * @returns {{ columns: number, chartWidth: number, chartHeight: number, gap: number, padding: number }} + */ +export function calculateChartDimensions(containerWidth, chartSize, gap = 32) { + const baseWidth = CHART_BASE_WIDTH_MAP[chartSize] || CHART_BASE_WIDTH_MAP.M; + const padding = CHART_PADDING_MAP[chartSize] || CHART_PADDING_MAP.M; + const aspectRatio = 16 / 9; + + const columns = Math.max(1, Math.floor(containerWidth / baseWidth)); + const totalGap = gap * (columns - 1); + const chartWidth = (containerWidth - totalGap) / columns; + + // Calculate height based on content width after padding + const contentWidth = chartWidth - padding * 2; + const chartHeight = contentWidth / aspectRatio; + + return { columns, chartWidth, chartHeight, gap, padding }; +} + +/** + * Apply grid layout to a container element. + * + * @param {HTMLElement} container - Container element to apply grid to + * @param {number} columns - Number of grid columns + * @param {number} gap - Gap between items in pixels + */ +export function applyGridLayout(container, columns, gap) { + container.style.display = 'grid'; + container.style.gridTemplateColumns = `repeat(${columns}, 1fr)`; + container.style.gap = `${gap}px`; + container.classList.remove('space-y-8'); +} + +/** + * Update heights of chart elements in a container. + * + * Only targets the chart divs (the divs that hold the canvas), never the + * title elements. Chart div ids start with `chart-`, while their associated + *

titles use the `-title` suffix and must not be resized. + * + * @param {HTMLElement} container - Container element with chart divs + * @param {number} chartHeight - Height to apply in pixels + * @param {string} [selector='div[id^="chart-"]'] - CSS selector for chart divs + */ +export function updateChartHeights(container, chartHeight, selector = 'div[id^="chart-"]') { + const chartDivs = container.querySelectorAll(selector); + for (const chartDiv of chartDivs) { + chartDiv.style.height = `${chartHeight}px`; + } +} + +/** + * Update padding of chart containers based on chart size. + * + * @param {HTMLElement} container - Container element with chart containers + * @param {string} chartSize - Chart size ('S', 'M', or 'L') + */ +export function updateContainerPadding(container, chartSize) { + const chartContainers = container.querySelectorAll(':scope > div'); + const paddingClass = chartSize === 'S' ? 'p-3' : 'p-6'; + const removePaddingClass = chartSize === 'S' ? 'p-6' : 'p-3'; + + for (const chartContainer of chartContainers) { + chartContainer.classList.remove(removePaddingClass); + chartContainer.classList.add(paddingClass); + } +} + +/** + * Update size toggle button styles. + * + * @param {Object} buttons - Object with size keys (S, M, L) and button elements as values + * @param {string} activeSize - Currently active size + */ +export function updateSizeButtonStyles(buttons, activeSize) { + for (const [size, button] of Object.entries(buttons)) { + if (!button) continue; + + if (size === activeSize) { + button.classList.add('bg-action', 'text-white'); + button.classList.remove('bg-base-surface', 'text-text-primary', 'hover:bg-base-bg'); + } else { + button.classList.add('bg-base-surface', 'text-text-primary', 'hover:bg-base-bg'); + button.classList.remove('bg-action', 'text-white', 'hover:bg-action-hover'); + } + } +} diff --git a/src/aspara/dashboard/static/js/metrics/metric-chart-factory.js b/src/aspara/dashboard/static/js/metrics/metric-chart-factory.js new file mode 100644 index 0000000000000000000000000000000000000000..ddf65bd3d39c4d8f380840d3e084d579c911ba54 --- /dev/null +++ b/src/aspara/dashboard/static/js/metrics/metric-chart-factory.js @@ -0,0 +1,68 @@ +/** + * Metric chart DOM factory. + * Creates chart container elements for metrics visualization. + */ +import { escapeHtml } from '../html-utils.js'; + +/** + * Create a metric chart container element. + * + * @param {string} metricName - Name of the metric + * @param {number} chartHeight - Height of the chart in pixels + * @param {string} [chartSize='M'] - Chart size ('S', 'M', or 'L') for padding adjustment + * @returns {{ container: HTMLElement, chartDiv: HTMLElement, chartId: string, titleId: string }} + */ +export function createMetricChartContainer(metricName, chartHeight, chartSize = 'M') { + const chartContainer = document.createElement('div'); + // Use smaller padding for S size to maximize chart area + const padding = chartSize === 'S' ? 'p-3' : 'p-6'; + chartContainer.className = `bg-base-surface border border-base-border ${padding}`; + + const chartId = `chart-${metricName.replace(/[^a-zA-Z0-9]/g, '_')}`; + // Stable id for the

title so the canvas can reference it via + // aria-labelledby (SSOT: the metric name lives only in the

). + const titleId = `${chartId}-title`; + + // Header section + const chartHeader = document.createElement('div'); + chartHeader.className = 'flex items-center justify-between mb-6'; + + const chartTitle = document.createElement('h3'); + chartTitle.id = titleId; + chartTitle.className = 'text-sm font-semibold text-text-primary uppercase tracking-wider'; + chartTitle.textContent = metricName; + chartHeader.appendChild(chartTitle); + + chartContainer.appendChild(chartHeader); + + // Chart div - use inline style for height to ensure it's set before Chart initialization + const chartDiv = document.createElement('div'); + chartDiv.id = chartId; + chartDiv.className = 'bg-base-bg'; + chartDiv.style.height = `${chartHeight}px`; + chartDiv.style.width = '100%'; + chartDiv.style.boxSizing = 'border-box'; + // Stash the title element id so Chart can wire up aria-labelledby on + // the canvas it creates inside this div. + chartDiv.dataset.ariaLabelledby = titleId; + chartContainer.appendChild(chartDiv); + + return { container: chartContainer, chartDiv, chartId, titleId }; +} + +/** + * Create an error display for a failed chart. + * + * @param {string} metricName - Name of the metric + * @param {string} errorMessage - Error message to display + * @returns {HTMLElement} + */ +export function createChartErrorDisplay(metricName, errorMessage) { + const container = document.createElement('div'); + container.className = 'bg-base-surface border border-base-border p-6'; + container.innerHTML = ` +

${escapeHtml(metricName)}

+
Error creating chart: ${escapeHtml(errorMessage)}
+ `; + return container; +} diff --git a/src/aspara/dashboard/static/js/metrics/metrics-data-service.js b/src/aspara/dashboard/static/js/metrics/metrics-data-service.js new file mode 100644 index 0000000000000000000000000000000000000000..42560f6554c5cbd51cb478b49fc467bd1f48edbc --- /dev/null +++ b/src/aspara/dashboard/static/js/metrics/metrics-data-service.js @@ -0,0 +1,502 @@ +/** + * Metrics data service. + * Handles API calls, caching, and SSE for real-time updates. + */ +import { decode as msgpackDecode } from '@msgpack/msgpack'; +import { INITIAL_SINCE_TIMESTAMP, buildSSEUrl } from '../runs-list/sse-utils.js'; +import { removeEventListeners } from '../html-utils.js'; +import { SSEReconnectManager } from '../sse-reconnect-manager.js'; +import { decompressDeltaData, findLatestTimestamp, mergeDataPoint } from './metrics-utils.js'; + +/** + * MetricsDataService handles fetching, caching, and real-time updates for metrics data. + */ +export class MetricsDataService { + // Cache constants + static MIN_CACHE_SIZE = 3; + static DEFAULT_MAX_CACHE_SIZE = 3; + + /** + * @param {string} project - Project name + * @param {Object} options - Configuration options + * @param {function} options.onMetricUpdate - Callback when metric is updated via SSE + * @param {function} options.onStatusUpdate - Callback when run status is updated via SSE + * @param {function} options.onCacheUpdated - Callback when cache is updated (for re-rendering) + * @param {function} [options.onConnectionStateChange] - Callback when SSE connection state changes. Receives (state, detail). + */ + constructor(project, options = {}) { + this.project = project; + this.onMetricUpdate = options.onMetricUpdate || null; + this.onStatusUpdate = options.onStatusUpdate || null; + this.onCacheUpdated = options.onCacheUpdated || null; + + // Cache mechanism: metric-first format {metric: {run: data}} + this.metricsCache = {}; + // Track which runs are cached + this.cachedRuns = new Set(); + // LRU cache management: track access order with O(1) lookup + this.cacheAccessOrder = []; + this.cacheAccessSet = new Set(); + this.minCacheSize = MetricsDataService.MIN_CACHE_SIZE; + this.maxCacheSize = MetricsDataService.DEFAULT_MAX_CACHE_SIZE; + + // SSE state + this.eventSource = null; + this.lastSSETimestamp = INITIAL_SINCE_TIMESTAMP; + this.lastEventTime = 0; + this.currentSSERuns = ''; + + // Reconnection state managed by SSEReconnectManager (SSOT for + // backoff constants, guard logic, and connection state notification). + this.reconnectManager = new SSEReconnectManager({ + onConnectionStateChange: options.onConnectionStateChange || null, + }); + + // SSE event handlers (stored for cleanup) + this.sseOpenHandler = null; + this.sseMetricHandler = null; + this.sseStatusHandler = null; + this.sseErrorHandler = null; + } + + /** + * Adjust cache size to accommodate selected runs. + * @param {number} selectedCount - Number of selected runs + */ + adjustCacheSize(selectedCount) { + this.maxCacheSize = Math.max(this.minCacheSize, selectedCount); + } + + /** + * Check which runs need to be fetched (not in cache). + * @param {Set} runNames - Set of run names + * @returns {Array} Array of run names that need to be fetched + */ + getRunsToFetch(runNames) { + const toFetch = []; + for (const runName of runNames) { + if (!this.cachedRuns.has(runName)) { + toFetch.push(runName); + } + } + return toFetch; + } + + /** + * Build the metrics API URL for a comma-separated run list. + * @param {string} runsList - Comma-separated run names + * @param {number|null} since - Optional SSE timestamp to fetch deltas from + * @returns {string} Metrics API URL + */ + _buildMetricsUrl(runsList, since = null) { + const sinceParam = since !== null ? `&since=${since}` : ''; + return `/api/projects/${encodeURIComponent(this.project)}/runs/metrics?runs=${encodeURIComponent(runsList)}&format=msgpack${sinceParam}`; + } + + /** + * Update lastSSETimestamp from a data object that may contain a timestamp. + * @param {Object} data - Object with an optional timestamp field + */ + _updateLastSSETimestamp(data) { + if (data.timestamp) { + const ts = new Date(data.timestamp).getTime(); + if (!Number.isNaN(ts) && ts > this.lastSSETimestamp) { + this.lastSSETimestamp = ts; + } + } + } + + /** + * Fetch metrics for specific runs and add to cache. + * @param {Array} runNames - Array of run names to fetch + * @returns {Promise} + */ + async fetchAndCacheMetrics(runNames) { + const runsList = runNames.join(','); + + const fetchStart = performance.now(); + const response = await fetch(this._buildMetricsUrl(runsList)); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Decode MessagePack binary response + const arrayBuffer = await response.arrayBuffer(); + const data = msgpackDecode(arrayBuffer); + const fetchEnd = performance.now(); + const fetchTime = fetchEnd - fetchStart; + + if (data.error) { + throw new Error(data.error); + } + + console.log(`šŸ“Š [Performance] API fetch time (MessagePack): ${fetchTime.toFixed(2)}ms`); + + // Decompress delta-compressed data (keep SoA format) + const convertedData = decompressDeltaData(data.metrics); + + // Store converted data in cache (run-by-run) + this.cacheMetricsData(convertedData, runNames); + + // Update lastSSETimestamp with the latest timestamp from fetched data + const latestTimestamp = findLatestTimestamp(convertedData); + if (latestTimestamp > this.lastSSETimestamp) { + this.lastSSETimestamp = latestTimestamp; + console.log('[SSE] Updated lastSSETimestamp from API data:', this.lastSSETimestamp); + } + + // Setup SSE for real-time updates + this.setupSSE(runsList); + } + + /** + * Get cached metrics for selected runs. + * @param {Set} selectedRuns - Set of selected run names + * @returns {Object} Filtered metrics data in metric-first format + */ + getCachedMetrics(selectedRuns) { + const metricsData = {}; + + for (const [metricName, runData] of Object.entries(this.metricsCache)) { + const filteredRunData = {}; + for (const [runName, data] of Object.entries(runData)) { + if (selectedRuns.has(runName)) { + filteredRunData[runName] = data; + // Update access order for selected runs + this.updateCacheAccess(runName); + } + } + if (Object.keys(filteredRunData).length > 0) { + metricsData[metricName] = filteredRunData; + } + } + + return metricsData; + } + + /** + * Update LRU cache access order. + * Uses Set for O(1) existence check instead of O(n) indexOf. + * @param {string} runName - Run name to mark as accessed + */ + updateCacheAccess(runName) { + if (this.cacheAccessSet.has(runName)) { + // Remove from current position - O(n) but only when item exists + const index = this.cacheAccessOrder.indexOf(runName); + if (index > -1) { + this.cacheAccessOrder.splice(index, 1); + } + } else { + this.cacheAccessSet.add(runName); + } + this.cacheAccessOrder.push(runName); + } + + /** + * Evict least recently used runs if cache is over capacity. + */ + evictLRU() { + while (this.cachedRuns.size > this.maxCacheSize) { + const oldestRun = this.cacheAccessOrder[0]; + if (!oldestRun) break; + + // Remove from all metrics + for (const metricData of Object.values(this.metricsCache)) { + delete metricData[oldestRun]; + } + + // Remove from tracking + this.cachedRuns.delete(oldestRun); + this.cacheAccessSet.delete(oldestRun); + this.cacheAccessOrder.shift(); + } + } + + /** + * Cache metrics data in metric-first format. + * @param {Object} metricsData - Metrics data in metric-first format {metric: {run: data}} + * @param {Array} runNames - Run names that were fetched + */ + cacheMetricsData(metricsData, runNames) { + // Merge new data into cache (metric-first format) + for (const [metricName, runData] of Object.entries(metricsData)) { + if (!this.metricsCache[metricName]) { + this.metricsCache[metricName] = {}; + } + for (const [runName, data] of Object.entries(runData)) { + this.metricsCache[metricName][runName] = data; + } + } + + // Track cached runs and update LRU + for (const runName of runNames) { + this.cachedRuns.add(runName); + this.updateCacheAccess(runName); + } + + // Evict if over capacity + this.evictLRU(); + } + + /** + * Setup Server-Sent Events for real-time metric updates. + * @param {string} runsList - Comma-separated list of run names + */ + setupSSE(runsList) { + this.closeSSE(); + this.currentSSERuns = runsList; + + const runsArray = runsList.split(','); + const sseUrl = buildSSEUrl(this.project, runsArray, this.lastSSETimestamp); + console.log('[SSE] Connecting with since:', this.lastSSETimestamp); + this.eventSource = new EventSource(sseUrl); + + // Store handlers as member variables for cleanup + this.sseOpenHandler = () => { + console.log('[SSE] Connection opened with since:', this.lastSSETimestamp); + // Reset reconnection state on successful connection + this.reconnectManager.resetReconnectState(); + this.reconnectManager.notifyConnectionState('connected'); + }; + + this.sseMetricHandler = (event) => { + try { + const metric = JSON.parse(event.data); + this._updateLastSSETimestamp(metric); + this.lastEventTime = Date.now(); + this.handleMetricUpdate(metric); + } catch (error) { + console.error('Error processing SSE metric:', error); + } + }; + + this.sseStatusHandler = (event) => { + try { + const statusData = JSON.parse(event.data); + this._updateLastSSETimestamp(statusData); + if (this.onStatusUpdate) { + this.onStatusUpdate(statusData); + } + } catch (error) { + console.error('Error processing status update:', error); + } + }; + + this.sseErrorHandler = () => { + console.log('[SSE] Connection error, readyState:', this.eventSource.readyState); + + if (this.eventSource.readyState !== EventSource.CLOSED) { + this.eventSource.close(); + } + + // Don't reset isReconnecting here - let reconnectSSE() manage the flag + // This prevents concurrent reconnection attempts + console.log('[SSE] Using custom reconnect with delta fetch, since:', this.lastSSETimestamp); + this.reconnectSSE(); + }; + + this.eventSource.addEventListener('open', this.sseOpenHandler); + this.eventSource.addEventListener('metric', this.sseMetricHandler); + this.eventSource.addEventListener('status', this.sseStatusHandler); + this.eventSource.addEventListener('error', this.sseErrorHandler); + } + + /** + * Reconnect SSE with updated since timestamp. + * Uses exponential backoff and max retry limit to prevent infinite loops. + */ + async reconnectSSE() { + if (!this.reconnectManager.canReconnect()) { + // If max attempts reached (not just "already reconnecting"), notify disconnected + if (!this.reconnectManager.isReconnecting && this.reconnectManager.reconnectAttempts >= this.reconnectManager.maxReconnectAttempts) { + console.error('[SSE] Max reconnection attempts reached, giving up'); + this.reconnectManager.notifyConnectionState('disconnected', { lastEventTime: this.lastEventTime }); + } + return; + } + + this.reconnectManager.beginReconnect(); + + // Exponential backoff via SSEReconnectManager + const delay = this.reconnectManager.calculateBackoffDelay(this.reconnectManager.reconnectAttempts); + console.log(`[SSE] Reconnect attempt ${this.reconnectManager.reconnectAttempts}/${this.reconnectManager.maxReconnectAttempts}, waiting ${delay}ms`); + + try { + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + + await new Promise((resolve) => setTimeout(resolve, delay)); + + if (!this.currentSSERuns) { + console.log('[SSE] No runs to reconnect'); + this.reconnectManager.isReconnecting = false; + return; + } + + console.log('[SSE] Reconnecting - fetching delta since:', this.lastSSETimestamp); + try { + await this.fetchDeltaViaMsgPack(this.currentSSERuns); + } catch (error) { + console.error('[SSE] Failed to fetch delta:', error); + // Continue to SSE connection even if delta fetch fails (data will sync on next fetch) + } + + console.log('[SSE] Reconnecting SSE with since:', this.lastSSETimestamp); + this.setupSSE(this.currentSSERuns); + // Reset isReconnecting after setupSSE() completes + // This allows the next error event to trigger a new reconnection attempt + this.reconnectManager.isReconnecting = false; + } catch (error) { + console.error('[SSE] Reconnection failed:', error); + this.reconnectManager.isReconnecting = false; + } + } + + /** + * Fetch delta data via MessagePack API and merge into cache. + * @param {string} runsList - Comma-separated list of run names + */ + async fetchDeltaViaMsgPack(runsList) { + const fetchStart = performance.now(); + const url = this._buildMetricsUrl(runsList, this.lastSSETimestamp); + + console.log('[SSE] Fetching delta from:', url); + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const data = msgpackDecode(arrayBuffer); + const fetchEnd = performance.now(); + + if (data.error) { + throw new Error(data.error); + } + + console.log(`šŸ“Š [SSE Reconnect] Delta fetch time (MessagePack): ${(fetchEnd - fetchStart).toFixed(2)}ms`); + + const deltaData = decompressDeltaData(data.metrics); + this.mergeDeltaIntoCache(deltaData); + + const latestTimestamp = findLatestTimestamp(deltaData); + if (latestTimestamp > this.lastSSETimestamp) { + this.lastSSETimestamp = latestTimestamp; + console.log('[SSE] Updated lastSSETimestamp from delta:', this.lastSSETimestamp); + } + + if (Object.keys(deltaData).length > 0 && this.onCacheUpdated) { + console.log('[SSE] Re-rendering charts after delta merge'); + this.onCacheUpdated(); + } + } + + /** + * Merge delta data into existing cache. + * @param {Object} deltaData - Delta data in metric-first format + */ + mergeDeltaIntoCache(deltaData) { + for (const [metricName, runData] of Object.entries(deltaData)) { + if (!this.metricsCache[metricName]) { + this.metricsCache[metricName] = {}; + } + + for (const [runName, newData] of Object.entries(runData)) { + if (!this.cachedRuns.has(runName)) { + continue; + } + + if (!this.metricsCache[metricName][runName]) { + this.metricsCache[metricName][runName] = newData; + continue; + } + + const cached = this.metricsCache[metricName][runName]; + for (let i = 0; i < newData.steps.length; i++) { + mergeDataPoint(cached, newData.steps[i], newData.values[i], newData.timestamps[i]); + } + } + } + + console.log('[SSE] Merged delta data into cache'); + } + + /** + * Handle metric update from SSE. + * @param {Object} metric - Metric record from SSE + */ + handleMetricUpdate(metric) { + console.log('[SSE] handleMetricUpdate called with:', metric); + + if (!metric.metrics || !metric.run) { + console.warn('[SSE] Invalid metric - missing metrics or run:', metric); + return; + } + + const runName = metric.run; + const step = metric.step || 0; + const timestamp = metric.timestamp ? new Date(metric.timestamp).getTime() : Date.now(); + + console.log(`[SSE] Processing metric: run=${runName}, step=${step}, metrics=${Object.keys(metric.metrics).join(',')}`); + + for (const [metricName, value] of Object.entries(metric.metrics)) { + // Update cache (SoA format) + if (this.cachedRuns.has(runName)) { + if (!this.metricsCache[metricName]) { + this.metricsCache[metricName] = {}; + } + if (!this.metricsCache[metricName][runName]) { + this.metricsCache[metricName][runName] = { + steps: [], + values: [], + timestamps: [], + }; + } + const cached = this.metricsCache[metricName][runName]; + mergeDataPoint(cached, step, value, timestamp); + } + + // Notify callback + if (this.onMetricUpdate) { + this.onMetricUpdate(metricName, runName, step, value); + } + } + } + + /** + * Close SSE connection. + */ + closeSSE() { + if (this.eventSource) { + removeEventListeners(this.eventSource, { + open: this.sseOpenHandler, + metric: this.sseMetricHandler, + status: this.sseStatusHandler, + error: this.sseErrorHandler, + }); + this.eventSource.close(); + this.eventSource = null; + } + this.sseOpenHandler = null; + this.sseMetricHandler = null; + this.sseStatusHandler = null; + this.sseErrorHandler = null; + this.currentSSERuns = ''; + } + + /** + * Destroy the service and clean up resources. + */ + destroy() { + this.closeSSE(); + this.metricsCache = {}; + this.cachedRuns.clear(); + this.cacheAccessOrder = []; + this.cacheAccessSet.clear(); + this.reconnectManager.resetReconnectState(); + } +} diff --git a/src/aspara/dashboard/static/js/metrics/metrics-utils.js b/src/aspara/dashboard/static/js/metrics/metrics-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..c75adc079cecf391421446f439a2bbe241d30193 --- /dev/null +++ b/src/aspara/dashboard/static/js/metrics/metrics-utils.js @@ -0,0 +1,130 @@ +/** + * Metrics data processing utilities. + * Pure functions for delta decompression and data transformation. + */ + +/** + * Decompress delta-compressed metrics data and keep SoA (Structure of Arrays) format. + * Input array format: {steps: [delta...], values: [...], timestamps: [delta...]} + * - steps and timestamps are delta-compressed (first value is absolute, rest are deltas) + * Output SoA format: {steps: [...], values: [...], timestamps: [...]} + * + * @param {Object} arrayData - Delta-compressed metrics data {metric: {run: {steps, values, timestamps}}} + * @returns {Object} Decompressed metrics data in SoA format + */ +export function decompressDeltaData(arrayData) { + const result = {}; + + for (const [metricName, runData] of Object.entries(arrayData)) { + result[metricName] = {}; + + for (const [runName, arrays] of Object.entries(runData)) { + const length = arrays.steps.length; + const steps = new Array(length); + const timestamps = new Array(length); + + // Decompress delta-encoded arrays + let step = 0; + let timestamp_ms = 0; + + for (let i = 0; i < length; i++) { + // Decode step (delta-compressed) + step += arrays.steps[i]; + steps[i] = step; + + // Decode timestamp (delta-compressed, unix time in ms) + timestamp_ms += arrays.timestamps[i]; + timestamps[i] = timestamp_ms; + } + + result[metricName][runName] = { + steps, + values: arrays.values, + timestamps, + }; + } + } + + return result; +} + +/** + * Convert metrics data to Chart.js compatible format. + * + * @param {string} metricName - Name of the metric + * @param {Object} runData - Run data in SoA format { runName: { steps: [], values: [], timestamps: [] } } + * @returns {Object} Chart data format { title: string, series: Array } + */ +export function convertToChartFormat(metricName, runData) { + const series = []; + + for (const [runName, data] of Object.entries(runData)) { + if (data?.steps?.length > 0) { + series.push({ + name: runName, + data: { steps: data.steps, values: data.values }, + }); + } + } + + return { + title: metricName, + series: series, + }; +} + +/** + * Find the latest timestamp from metrics data. + * + * @param {Object} metricsData - Metrics data in SoA format {metric: {run: {steps, values, timestamps}}} + * @returns {number} Latest timestamp in milliseconds, or 0 if no data + */ +export function findLatestTimestamp(metricsData) { + let latestTimestamp = 0; + + for (const runData of Object.values(metricsData)) { + for (const data of Object.values(runData)) { + if (data.timestamps && data.timestamps.length > 0) { + const lastTs = data.timestamps[data.timestamps.length - 1]; + if (lastTs > latestTimestamp) { + latestTimestamp = lastTs; + } + } + } + } + + return latestTimestamp; +} + +/** + * Merge new data point into cached data using binary search for sorted insertion. + * + * @param {Object} cached - Cached data { steps: [], values: [], timestamps: [] } + * @param {number} step - Step number + * @param {number} value - Metric value + * @param {number} timestamp - Timestamp in milliseconds + */ +export function mergeDataPoint(cached, step, value, timestamp) { + // Binary search to find insertion point for sorted order by step + let left = 0; + let right = cached.steps.length; + + while (left < right) { + const mid = (left + right) >> 1; + if (cached.steps[mid] < step) { + left = mid + 1; + } else if (cached.steps[mid] > step) { + right = mid; + } else { + // Step already exists - update the value in place + cached.values[mid] = value; + cached.timestamps[mid] = timestamp; + return; + } + } + + // Insert at sorted position + cached.steps.splice(left, 0, step); + cached.values.splice(left, 0, value); + cached.timestamps.splice(left, 0, timestamp); +} diff --git a/src/aspara/dashboard/static/js/note-editor-utils.js b/src/aspara/dashboard/static/js/note-editor-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..3e5dae52ad570361d29a7a3038ed3c53d421cb9d --- /dev/null +++ b/src/aspara/dashboard/static/js/note-editor-utils.js @@ -0,0 +1,71 @@ +/** + * Pure utility functions for note editor + * These functions have no side effects and are easy to test + */ + +import { ICON_EDIT, escapeHtml } from './html-utils.js'; + +/** + * Placeholder text shown when note is empty + */ +export const EMPTY_NOTE_PLACEHOLDER = 'Add note...'; + +/** + * Format note content for display (escape HTML and convert newlines) + * @param {string} note - Raw note text + * @returns {string} HTML-safe note with newlines as
+ */ +export function formatNoteForDisplay(note) { + if (!note) { + return `${EMPTY_NOTE_PLACEHOLDER}`; + } + return escapeHtml(note).replace(/\n/g, '
'); +} + +/** + * Check if note content is empty or just placeholder + * @param {string} text - Text content to check + * @returns {boolean} True if note is effectively empty + */ +export function isNoteEmpty(text) { + if (!text) return true; + const trimmed = text.trim(); + return trimmed === '' || trimmed === EMPTY_NOTE_PLACEHOLDER; +} + +/** + * Extract actual note text from content element text + * @param {string} contentText - Text content from note display element + * @returns {string} Actual note text (empty string if placeholder) + */ +export function extractNoteText(contentText) { + if (!contentText) return ''; + const trimmed = contentText.trim(); + return trimmed === EMPTY_NOTE_PLACEHOLDER ? '' : trimmed; +} + +/** + * Create request body for saving note + * @param {string} note - Note text + * @returns {string} JSON string for request body + */ +export function createSaveNoteRequestBody(note) { + return JSON.stringify({ notes: note }); +} + +/** + * Parse save note response + * @param {Object} responseData - Response data from API + * @returns {string} Note text from response + */ +export function extractNoteFromResponse(responseData) { + return responseData.notes || ''; +} + +/** + * Create edit button HTML + * @returns {string} Edit button HTML + */ +export function getEditButtonHTML() { + return `${ICON_EDIT} Edit`; +} diff --git a/src/aspara/dashboard/static/js/note-editor.js b/src/aspara/dashboard/static/js/note-editor.js new file mode 100644 index 0000000000000000000000000000000000000000..53d42559dfeb73a8c6e2359dc88af64b5389c7aa --- /dev/null +++ b/src/aspara/dashboard/static/js/note-editor.js @@ -0,0 +1,372 @@ +/** + * Inline note editor functionality + * GitHub Issue-style inline editing for project/run notes + */ + +import { isDev } from './dev-mode.js'; +import { + EMPTY_NOTE_PLACEHOLDER, + createSaveNoteRequestBody, + extractNoteFromResponse, + extractNoteText, + formatNoteForDisplay, + getEditButtonHTML, + isNoteEmpty, +} from './note-editor-utils.js'; +import { guardReadOnly } from './read-only-guard.js'; + +/** + * Maximum notes length, sourced from the server-rendered + * `data-max-notes-length` attribute on so the server remains the + * single source of truth for the validation bound. + * @returns {number} max notes length in characters (falls back to Infinity + * if the attribute is absent, leaving validation to the server) + */ +function getMaxNotesLength() { + const raw = document.body.dataset.maxNotesLength; + const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : Number.POSITIVE_INFINITY; +} + +class NoteEditor { + constructor() { + this.isEditing = false; + this.originalValue = ''; + this.currentElement = null; + this.currentApiEndpoint = null; + this._abortController = null; + } + + /** + * Initialize note editor for an element + * @param {HTMLElement} element - The note display element + * @param {string} apiEndpoint - API endpoint for updating note + * @param {string} currentNote - Current note content + * @param {string} editButtonContainerId - ID of the container for the edit button + */ + init(element, apiEndpoint, currentNote = '', editButtonContainerId = null) { + if (!element || !apiEndpoint) return; + + this._abortController = new AbortController(); + const { wrapper, editBtn } = this.createNoteWrapper(element, currentNote, editButtonContainerId); + element.parentNode.replaceChild(wrapper, element); + + this.attachEventListeners(wrapper, apiEndpoint, editBtn); + } + + /** + * Create note wrapper with display and edit elements + */ + createNoteWrapper(originalElement, note, editButtonContainerId) { + const wrapper = document.createElement('div'); + wrapper.className = 'note-editor-wrapper'; + + const display = document.createElement('div'); + display.className = 'note-display'; + display.innerHTML = `
${formatNoteForDisplay(note)}
`; + + // Create edit button + const editBtn = document.createElement('button'); + editBtn.className = 'note-edit-btn py-1 text-sm text-accent hover:text-accent-hover transition-colors'; + editBtn.innerHTML = getEditButtonHTML(); + + // Place edit button in specified container or in display + if (editButtonContainerId) { + const buttonContainer = document.getElementById(editButtonContainerId); + if (buttonContainer) { + buttonContainer.appendChild(editBtn); + } + } else { + display.appendChild(editBtn); + } + + // Edit element (hidden by default) + const edit = document.createElement('div'); + edit.className = 'note-edit hidden'; + edit.innerHTML = ` + +
+ + +
+ + `; + + // Apply the server-provided notes length limit (SSOT) to the textarea. + const textarea = edit.querySelector('.note-textarea'); + const maxNotesLength = getMaxNotesLength(); + if (Number.isFinite(maxNotesLength)) { + textarea.maxLength = maxNotesLength; + } + + wrapper.appendChild(display); + wrapper.appendChild(edit); + + return { wrapper, editBtn }; + } + + /** + * Attach event listeners to note wrapper + */ + attachEventListeners(wrapper, apiEndpoint, editBtn) { + const signal = this._abortController.signal; + const saveBtn = wrapper.querySelector('.note-save-btn'); + const cancelBtn = wrapper.querySelector('.note-cancel-btn'); + const textarea = wrapper.querySelector('.note-textarea'); + const noteContent = wrapper.querySelector('.note-content'); + + if (editBtn) { + editBtn.addEventListener('click', () => this.startEditing(wrapper, apiEndpoint), { signal }); + } + + // Click on placeholder text enters edit mode + if (noteContent) { + noteContent.addEventListener( + 'click', + () => { + if (isNoteEmpty(noteContent.textContent)) { + this.startEditing(wrapper, apiEndpoint); + } + }, + { signal } + ); + } + + if (saveBtn) { + saveBtn.addEventListener('click', () => this.saveNote(wrapper, apiEndpoint), { signal }); + } + if (cancelBtn) { + cancelBtn.addEventListener('click', () => this.cancelEditing(wrapper), { signal }); + } + + // Save on Ctrl+Enter, Cancel on Escape + if (textarea) { + textarea.addEventListener( + 'keydown', + (e) => { + if (e.key === 'Enter' && e.ctrlKey) { + this.saveNote(wrapper, apiEndpoint); + } else if (e.key === 'Escape') { + this.cancelEditing(wrapper); + } + }, + { signal } + ); + } + } + + /** + * Start editing mode + */ + startEditing(wrapper, apiEndpoint) { + if (guardReadOnly()) return; + if (this.isEditing) return; // Prevent multiple edits + + this.isEditing = true; + this.currentElement = wrapper; + this.currentApiEndpoint = apiEndpoint; + + const display = wrapper.querySelector('.note-display'); + const edit = wrapper.querySelector('.note-edit'); + const textarea = wrapper.querySelector('.note-textarea'); + const noteContent = wrapper.querySelector('.note-content'); + + // Get current note text (strip HTML and handle empty state) + const currentText = extractNoteText(noteContent.textContent); + this.originalValue = currentText; + + display.classList.add('hidden'); + edit.classList.remove('hidden'); + textarea.value = currentText; + textarea.focus(); + } + + /** + * Cancel editing and restore original state + */ + cancelEditing(wrapper) { + if (!this.isEditing) return; + + const display = wrapper.querySelector('.note-display'); + const edit = wrapper.querySelector('.note-edit'); + const textarea = wrapper.querySelector('.note-textarea'); + const errorDiv = wrapper.querySelector('.note-error'); + + // Reset textarea to original content and hide any error message + textarea.value = this.originalValue; + errorDiv.classList.add('hidden'); + + edit.classList.add('hidden'); + display.classList.remove('hidden'); + + this.resetEditingState(); + } + + /** + * Save note to server + */ + async saveNote(wrapper, apiEndpoint) { + if (!this.isEditing) return; + + const textarea = wrapper.querySelector('.note-textarea'); + const saveBtn = wrapper.querySelector('.note-save-btn'); + const errorDiv = wrapper.querySelector('.note-error'); + const newNote = textarea.value.trim(); + + // Show loading state + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + errorDiv.classList.add('hidden'); + + try { + const response = await fetch(apiEndpoint, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: createSaveNoteRequestBody(newNote), + }); + + if (!response.ok) { + let detail = `Server error: ${response.status}`; + let rawBody = null; + try { + const errorData = await response.json(); + detail = errorData.detail || detail; + } catch { + if (isDev()) { + try { + rawBody = await response.text(); + } catch { + // ignore + } + } + } + throw new Error(isDev() && rawBody ? `${detail} (raw: ${rawBody.slice(0, 200)})` : detail); + } + + let updatedMetadata; + try { + updatedMetadata = await response.json(); + } catch { + throw new Error('Failed to parse server response'); + } + + this.updateDisplay(wrapper, extractNoteFromResponse(updatedMetadata)); + this.finishEditing(wrapper); + } catch (error) { + console.error('Error saving note:', error); + this.showError(wrapper, error.message || 'Failed to save note'); + + // Reset save button + saveBtn.disabled = false; + saveBtn.textContent = 'Save'; + } + } + + /** + * Update the note display with new content + */ + updateDisplay(wrapper, note) { + const noteContent = wrapper.querySelector('.note-content'); + noteContent.innerHTML = formatNoteForDisplay(note); + } + + /** + * Finish editing and return to display mode + */ + finishEditing(wrapper) { + const display = wrapper.querySelector('.note-display'); + const edit = wrapper.querySelector('.note-edit'); + const saveBtn = wrapper.querySelector('.note-save-btn'); + + edit.classList.add('hidden'); + display.classList.remove('hidden'); + + // Re-enable save button with original text + saveBtn.disabled = false; + saveBtn.textContent = 'Save'; + + this.resetEditingState(); + } + + /** + * Show error message + */ + showError(wrapper, message) { + const errorDiv = wrapper.querySelector('.note-error'); + errorDiv.textContent = message; + errorDiv.classList.remove('hidden'); + } + + /** + * Reset editing state + */ + resetEditingState() { + this.isEditing = false; + this.originalValue = ''; + this.currentElement = null; + this.currentApiEndpoint = null; + } + + /** + * Remove all event listeners and clean up. + * Call this when the editor is no longer needed to prevent memory leaks. + */ + destroy() { + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + } + this.resetEditingState(); + } +} + +/** + * Initialize note editor from DOM element with data attributes + * Expected data attributes on the element: + * - data-api-endpoint: API endpoint for saving/loading notes + * - data-edit-btn-id: ID of the edit button container + * + * @param {string} elementId - ID of the note element + * @returns {Promise} The initialized NoteEditor instance, or null if the element was not found (for cleanup via destroy()) + */ +async function initNoteEditorFromDOM(elementId) { + const noteElement = document.getElementById(elementId); + if (!noteElement) return null; + + const apiEndpoint = noteElement.dataset.apiEndpoint; + const editBtnId = noteElement.dataset.editBtnId; + + if (!apiEndpoint) { + console.error(`Note editor: missing data-api-endpoint on #${elementId}`); + return null; + } + + const noteEditor = new NoteEditor(); + + try { + const response = await fetch(apiEndpoint); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const metadata = await response.json(); + noteEditor.init(noteElement, apiEndpoint, metadata.notes || '', editBtnId); + } catch (error) { + console.error(`Error loading note for #${elementId}:`, error); + noteEditor.init(noteElement, apiEndpoint, '', editBtnId); + } + return noteEditor; +} + +// Export for use in other scripts +export { NoteEditor, initNoteEditorFromDOM }; +window.NoteEditor = NoteEditor; +window.initNoteEditorFromDOM = initNoteEditorFromDOM; diff --git a/src/aspara/dashboard/static/js/notifications.js b/src/aspara/dashboard/static/js/notifications.js new file mode 100644 index 0000000000000000000000000000000000000000..bc91b5d206ca8b7759757dbbb19ed8f0cb8d0bd4 --- /dev/null +++ b/src/aspara/dashboard/static/js/notifications.js @@ -0,0 +1,188 @@ +/** + * Notification system for displaying success and error messages + */ + +/** + * Get CSS classes for notification type + * @param {string} type - The notification type + * @returns {string} CSS classes + */ +export function getNotificationStyles(type) { + switch (type) { + case 'success': + return 'bg-green-50 text-green-800 border border-green-200'; + case 'error': + return 'bg-red-50 text-red-800 border border-red-200'; + case 'warning': + return 'bg-yellow-50 text-yellow-800 border border-yellow-200'; + default: + return 'bg-blue-50 text-blue-800 border border-blue-200'; + } +} + +/** + * Get icon SVG for notification type + * @param {string} type - The notification type + * @returns {string} SVG icon HTML + */ +export function getNotificationIcon(type) { + switch (type) { + case 'success': + return ` + + + + `; + case 'error': + return ` + + + + `; + case 'warning': + return ` + + + + `; + default: + return ` + + + + `; + } +} + +/** + * Show a notification message + * @param {string} message - The message to display + * @param {string} type - The type of notification ('success', 'error', 'info', 'warning') + * @param {number} duration - Duration in milliseconds before auto-hide (default: 5000) + */ +function showNotification(message, type = 'info', duration = 5000) { + const container = document.getElementById('notification-container'); + if (!container) { + console.error('Notification container not found'); + return; + } + + // Create notification element + const notification = document.createElement('div'); + notification.className = ` + relative flex items-center px-4 py-3 max-w-sm w-full + text-sm font-medium rounded-md shadow-lg + transform transition-all duration-300 ease-in-out + translate-y-0 opacity-100 + ${getNotificationStyles(type)} + ` + .trim() + .replace(/\s+/g, ' '); + + // Create message content + const messageContent = document.createElement('div'); + messageContent.className = 'flex items-center flex-1'; + + // Add icon based on type + const icon = document.createElement('div'); + icon.className = 'mr-3 flex-shrink-0'; + icon.innerHTML = getNotificationIcon(type); + + const messageText = document.createElement('span'); + messageText.textContent = message; + + messageContent.appendChild(icon); + messageContent.appendChild(messageText); + + // Create close button + const closeButton = document.createElement('button'); + closeButton.className = 'ml-3 flex-shrink-0 hover:opacity-70 transition-opacity'; + closeButton.innerHTML = ` + + + + `; + closeButton.onclick = () => hideNotification(notification); + + notification.appendChild(messageContent); + notification.appendChild(closeButton); + + // Add to container + container.appendChild(notification); + + // Animate in + requestAnimationFrame(() => { + notification.style.transform = 'translateY(0) scale(1)'; + }); + + // Auto-hide after duration + if (duration > 0) { + setTimeout(() => { + hideNotification(notification); + }, duration); + } + + return notification; +} + +/** + * Hide a notification with animation + * @param {HTMLElement} notification - The notification element to hide + */ +function hideNotification(notification) { + if (!notification || !notification.parentNode) return; + + // Animate out + notification.style.transform = 'translateY(-100%) scale(0.95)'; + notification.style.opacity = '0'; + + // Remove from DOM after animation + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 300); +} + +/** + * Show a success notification + * @param {string} message - Success message + * @param {number} duration - Duration in milliseconds + */ +function showSuccessNotification(message, duration = 5000) { + return showNotification(message, 'success', duration); +} + +/** + * Show an error notification + * @param {string} message - Error message + * @param {number} duration - Duration in milliseconds (0 = no auto-hide) + */ +function showErrorNotification(message, duration = 8000) { + return showNotification(message, 'error', duration); +} + +/** + * Show an info notification + * @param {string} message - Info message + * @param {number} duration - Duration in milliseconds + */ +function showInfoNotification(message, duration = 5000) { + return showNotification(message, 'info', duration); +} + +/** + * Show a warning notification + * @param {string} message - Warning message + * @param {number} duration - Duration in milliseconds + */ +function showWarningNotification(message, duration = 6000) { + return showNotification(message, 'warning', duration); +} + +// Make functions available globally +window.showNotification = showNotification; +window.showSuccessNotification = showSuccessNotification; +window.showErrorNotification = showErrorNotification; +window.showInfoNotification = showInfoNotification; +window.showWarningNotification = showWarningNotification; diff --git a/src/aspara/dashboard/static/js/pages/base-chart-page.js b/src/aspara/dashboard/static/js/pages/base-chart-page.js new file mode 100644 index 0000000000000000000000000000000000000000..3b7f0920042c86e6cd2c10daec696c6021abbdf2 --- /dev/null +++ b/src/aspara/dashboard/static/js/pages/base-chart-page.js @@ -0,0 +1,338 @@ +import { escapeHtml } from '../html-utils.js'; +/** + * Base class for chart pages. + * Provides common chart layout management functionality shared between + * RunDetail and ProjectDetail pages. + */ +import { + CHART_SIZE_KEY, + applyGridLayout, + calculateChartDimensions, + updateChartHeights, + updateContainerPadding, + updateSizeButtonStyles, +} from '../metrics/chart-layout.js'; +import { createChartErrorDisplay, createMetricChartContainer } from '../metrics/metric-chart-factory.js'; + +export { CHART_SIZE_KEY }; + +/** + * BaseChartPage provides shared chart layout and sizing functionality. + * Subclasses should set chartsContainer, chartControls, and size buttons + * in their initializeElements() method. + */ +export class BaseChartPage { + constructor() { + this.charts = new Map(); + this.chartSize = localStorage.getItem(CHART_SIZE_KEY) || 'M'; + + // These will be set by initializeElements() + this.chartsContainer = null; + this.chartControls = null; + this.sizeSBtn = null; + this.sizeMBtn = null; + this.sizeLBtn = null; + + // Customizable properties (can be overridden by subclass before calling init methods) + this.chartsContainerId = 'chartsContainer'; + this.emptyStateMessage = 'No metrics available.'; + + // Window event handlers (stored for cleanup) + this.resizeHandler = null; + this.fullWidthChangedHandler = null; + this.resizeTimeout = null; + this.sizeHandlers = []; + } + + /** + * Initialize common DOM elements. + * Subclasses can override and call super.initializeElements() first. + */ + initializeElements() { + this.chartsContainer = document.getElementById(this.chartsContainerId); + this.chartControls = document.getElementById('chartControls'); + this.sizeSBtn = document.getElementById('sizeS'); + this.sizeMBtn = document.getElementById('sizeM'); + this.sizeLBtn = document.getElementById('sizeL'); + } + + /** + * Common initialization sequence. + * Call this after setting chartsContainerId and other properties. + */ + init() { + this.initializeElements(); + this.initSizeControls(); + this.initResizeHandlers(); + } + + /** + * Initialize size button click handlers. + */ + initSizeControls() { + const sizeButtons = [ + [this.sizeSBtn, 'S'], + [this.sizeMBtn, 'M'], + [this.sizeLBtn, 'L'], + ]; + for (const [btn, size] of sizeButtons) { + if (btn) { + const handler = () => this.setChartSize(size); + btn.addEventListener('click', handler); + this.sizeHandlers.push({ element: btn, handler }); + } + } + this.updateSizeButtons(); + } + + /** + * Initialize window resize and fullWidthChanged event handlers. + * Call this after initializeElements() in subclass constructor. + */ + initResizeHandlers() { + // Store handlers for cleanup + this.resizeHandler = () => { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = setTimeout(() => { + this.applyAutoLayout(); + }, 150); + }; + window.addEventListener('resize', this.resizeHandler); + + this.fullWidthChangedHandler = () => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.applyAutoLayout(); + }); + }); + }; + window.addEventListener('fullWidthChanged', this.fullWidthChangedHandler); + } + + /** + * Calculate chart dimensions based on container width and current size setting. + * + * @returns {{ columns: number, chartWidth: number, chartHeight: number, gap: number }} + */ + calculateChartDimensions() { + if (!this.chartsContainer) { + return { columns: 1, chartWidth: 400, chartHeight: 225, gap: 32 }; + } + + const containerWidth = this.chartsContainer.clientWidth; + return calculateChartDimensions(containerWidth, this.chartSize); + } + + /** + * Apply grid layout to charts container and update chart sizes. + */ + applyAutoLayout() { + if (!this.chartsContainer) return; + // When the charts area is hidden (e.g. no runs selected), skip layout. Otherwise + // applyGridLayout sets display:grid inline and overrides Tailwind .hidden — the + // empty charts region stays visible after deselecting all runs. + if (this.chartsContainer.classList.contains('hidden')) { + return; + } + + const { columns, chartHeight, gap } = this.calculateChartDimensions(); + + applyGridLayout(this.chartsContainer, columns, gap); + updateChartHeights(this.chartsContainer, chartHeight); + updateContainerPadding(this.chartsContainer, this.chartSize); + + requestAnimationFrame(() => { + for (const chart of this.charts.values()) { + if (chart.updateSize) { + chart.updateSize(); + } + } + }); + } + + /** + * Set chart size and persist to localStorage. + * + * @param {string} size - Chart size ('S', 'M', or 'L') + */ + setChartSize(size) { + this.chartSize = size; + localStorage.setItem(CHART_SIZE_KEY, size); + this.updateSizeButtons(); + this.applyAutoLayout(); + } + + /** + * Update size button visual states based on current chart size. + */ + updateSizeButtons() { + const buttons = { + S: this.sizeSBtn, + M: this.sizeMBtn, + L: this.sizeLBtn, + }; + updateSizeButtonStyles(buttons, this.chartSize); + } + + /** + * Show chart controls (size buttons, etc). + */ + showChartControls() { + if (this.chartControls) { + this.chartControls.classList.remove('hidden'); + } + } + + /** + * Hide chart controls. + */ + hideChartControls() { + if (this.chartControls) { + this.chartControls.classList.add('hidden'); + } + } + + /** + * Create a metric chart with container and error handling. + * Uses Template Method pattern - subclasses implement initializeChart(). + * + * @param {string} metricName - Name of the metric + * @param {object} metricData - Metric data (format depends on subclass) + */ + createMetricChart(metricName, metricData) { + const { chartHeight } = this.calculateChartDimensions(); + const { container, chartId } = createMetricChartContainer(metricName, chartHeight, this.chartSize); + this.chartsContainer.appendChild(container); + + try { + const chart = this.initializeChart(chartId, metricName, metricData, container); + if (chart) { + this.charts.set(metricName, chart); + } + } catch (error) { + console.error(`Error creating chart for ${metricName}:`, error); + container.innerHTML = ''; + const errorDisplay = createChartErrorDisplay(metricName, error.message); + container.appendChild(errorDisplay); + } + } + + /** + * Template method for chart initialization. + * Subclasses must override this to create their specific chart type. + * + * @param {string} chartId - DOM ID for the chart element + * @param {string} metricName - Name of the metric + * @param {object} metricData - Metric data + * @param {HTMLElement} container - Container element for the chart + * @returns {object|null} - Chart instance or null if no data + */ + initializeChart(chartId, metricName, metricData, container) { + throw new Error('Subclasses must implement initializeChart()'); + } + + /** + * Clear charts container and render charts for the given metrics data. + * Common logic for clearing and iterating over metrics. + * + * @param {object} metricsData - Object mapping metric names to data + * @returns {boolean} - True if charts were rendered, false if no data + */ + clearAndRenderCharts(metricsData) { + this.chartsContainer.innerHTML = ''; + this.charts.clear(); + + if (!metricsData || Object.keys(metricsData).length === 0) { + // applyGridLayout() sets display:grid inline; that beats Tailwind .hidden, so the + // charts area can stay visible with an empty container unless we clear inline styles. + this.resetChartsContainerLayoutStyles(); + return false; + } + + this.showChartControls(); + + // Temporarily show container to get accurate width for layout calculation + const wasHidden = this.chartsContainer.classList.contains('hidden'); + if (wasHidden) { + this.chartsContainer.classList.remove('hidden'); + } + + this.applyAutoLayout(); + + for (const [metricName, data] of Object.entries(metricsData)) { + this.createMetricChart(metricName, data); + } + + // Restore hidden state if it was hidden (caller will show it when ready) + if (wasHidden) { + this.chartsContainer.classList.add('hidden'); + } + + return true; + } + + /** + * Clear grid layout inline styles from applyGridLayout / applyAutoLayout so + * visibility is controlled only by classes (e.g. hidden). + */ + resetChartsContainerLayoutStyles() { + if (!this.chartsContainer) return; + this.chartsContainer.style.display = ''; + this.chartsContainer.style.gridTemplateColumns = ''; + this.chartsContainer.style.gap = ''; + this.chartsContainer.classList.add('space-y-8'); + } + + /** + * Render metrics data. Shows empty state if no data available. + * + * @param {object} metricsData - Object mapping metric names to data + */ + renderMetrics(metricsData) { + if (!this.clearAndRenderCharts(metricsData)) { + this.chartsContainer.innerHTML = ` +
+

${this.emptyStateMessage}

+
+ `; + this.hideChartControls(); + } + } + + /** + * Show error message in the charts container. + * + * @param {string} message - Error message to display + */ + showError(message) { + this.chartsContainer.innerHTML = ` +
+

An error occurred: ${escapeHtml(message)}

+
+ `; + this.hideChartControls(); + } + + /** + * Clean up event listeners and resources. + */ + destroy() { + if (this.resizeHandler) { + window.removeEventListener('resize', this.resizeHandler); + this.resizeHandler = null; + } + if (this.fullWidthChangedHandler) { + window.removeEventListener('fullWidthChanged', this.fullWidthChangedHandler); + this.fullWidthChangedHandler = null; + } + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = null; + } + for (const { element, handler } of this.sizeHandlers) { + element.removeEventListener('click', handler); + } + this.sizeHandlers = []; + this.charts.clear(); + } +} diff --git a/src/aspara/dashboard/static/js/pages/project-detail.js b/src/aspara/dashboard/static/js/pages/project-detail.js new file mode 100644 index 0000000000000000000000000000000000000000..ace63d98fbdcf1d699460c432f7850fdb9c42058 --- /dev/null +++ b/src/aspara/dashboard/static/js/pages/project-detail.js @@ -0,0 +1,400 @@ +/** + * Project detail page entry point. + * Orchestrates run selection, metrics visualization, and real-time updates. + */ +import { Chart } from '../chart.js'; +import { RunSelector } from '../components/run-selector.js'; +import { SSEStatusIndicator } from '../components/sse-status-indicator.js'; +import { registerPageLifecycle } from '../lifecycle.js'; +import { MetricsDataService } from '../metrics/metrics-data-service.js'; +import { convertToChartFormat } from '../metrics/metrics-utils.js'; +import { initNoteEditorFromDOM } from '../note-editor.js'; +import { updateRunStatusIcon } from '../runs-list/sse-utils.js'; +import { BaseChartPage, CHART_SIZE_KEY } from './base-chart-page.js'; + +// LocalStorage keys +const SIDEBAR_COLLAPSED_KEY = 'aspara-sidebar-collapsed'; + +/** + * ProjectDetail manages the project detail page. + * Extends BaseChartPage for shared chart layout functionality. + */ +class ProjectDetail extends BaseChartPage { + constructor() { + super(); + this.currentProject = ''; + this.syncZoom = localStorage.getItem('sync_zoom') === 'true'; + this.globalZoomState = null; + this.dataService = null; + this.runSelector = null; + this.sseIndicator = null; + + // Project-specific event handlers (stored for cleanup in destroy()) + this.syncZoomHandler = null; + this.sidebarToggleHandler = null; + + // Monotonic ID used to discard stale showMetrics() results. + // Incremented on every showMetrics() invocation; the in-flight request + // captures the ID and bails out after `await` if a newer request has + // superseded it. + this._metricsRequestId = 0; + + // Note editor instance (stored for cleanup in destroy()) + this.noteEditor = null; + + this.init(); + this.setupProjectSpecificListeners(); + this.initializeNoteEditor(); + this.loadInitialData(); + } + + async initializeNoteEditor() { + this.noteEditor = await initNoteEditorFromDOM('project-note'); + } + + initializeElements() { + super.initializeElements(); + + this.loadingState = document.getElementById('loadingState'); + this.noDataState = document.getElementById('noDataState'); + this.initialState = document.getElementById('initialState'); + this.syncZoomCheckbox = document.getElementById('syncZoom'); + + // Sidebar elements + this.sidebar = document.getElementById('runs-sidebar'); + this.toggleSidebarBtn = document.getElementById('toggleSidebar'); + this.sidebarCollapseIcon = document.getElementById('sidebar-collapse-icon'); + this.sidebarExpandIcon = document.getElementById('sidebar-expand-icon'); + this.sidebarExpandedContent = document.getElementById('sidebar-expanded-content'); + this.sidebarCollapsedContent = document.getElementById('sidebar-collapsed-content'); + this.sidebarTitle = document.getElementById('sidebar-title'); + } + + setupProjectSpecificListeners() { + // Setup sync zoom event listener + if (this.syncZoomCheckbox) { + this.syncZoomCheckbox.checked = this.syncZoom; + this.syncZoomHandler = (e) => { + this.syncZoom = e.target.checked; + localStorage.setItem('sync_zoom', this.syncZoom); + if (!this.syncZoom) { + this.globalZoomState = null; + } + }; + this.syncZoomCheckbox.addEventListener('change', this.syncZoomHandler); + } + + // Setup sidebar toggle + this.initSidebarToggle(); + } + + initSidebarToggle() { + if (!this.sidebar || !this.toggleSidebarBtn) return; + + const isCollapsed = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true'; + if (isCollapsed) { + this.collapseSidebar(); + } + + this.sidebarToggleHandler = () => { + const collapsed = this.sidebar.dataset.collapsed === 'true'; + if (collapsed) { + this.expandSidebar(); + } else { + this.collapseSidebar(); + } + }; + this.toggleSidebarBtn.addEventListener('click', this.sidebarToggleHandler); + } + + _setSidebarCollapsed(collapsed) { + if (!this.sidebar) return; + + this.sidebar.dataset.collapsed = String(collapsed); + this.sidebar.classList.toggle('w-80', !collapsed); + this.sidebar.classList.toggle('w-16', collapsed); + + this.sidebarCollapseIcon?.classList.toggle('hidden', collapsed); + this.sidebarExpandIcon?.classList.toggle('hidden', !collapsed); + this.sidebarExpandedContent?.classList.toggle('hidden', collapsed); + this.sidebarCollapsedContent?.classList.toggle('hidden', !collapsed); + this.sidebarTitle?.classList.toggle('hidden', collapsed); + + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(collapsed)); + this.resizeChartsAfterTransition(); + } + + collapseSidebar() { + this._setSidebarCollapsed(true); + } + + expandSidebar() { + this._setSidebarCollapsed(false); + } + + /** + * Toggle the visibility of the four main UI sections by page state. + * @param {'initial'|'loading'|'noData'|'metrics'} state - Page state name + */ + _setPageState(state) { + const hidden = { + initial: { loading: true, charts: true, noData: true, initial: false }, + loading: { loading: false, charts: true, noData: true, initial: true }, + noData: { loading: true, charts: true, noData: false, initial: true }, + metrics: { loading: true, charts: false, noData: true, initial: true }, + }[state]; + if (!hidden) return; + this.loadingState.classList.toggle('hidden', hidden.loading); + this.chartsContainer.classList.toggle('hidden', hidden.charts); + this.noDataState.classList.toggle('hidden', hidden.noData); + this.initialState.classList.toggle('hidden', hidden.initial); + } + + resizeChartsAfterTransition() { + if (!this.sidebar) return; + + const handleTransitionEnd = (e) => { + if (e.target !== this.sidebar || e.propertyName !== 'width') return; + this.sidebar.removeEventListener('transitionend', handleTransitionEnd); + this.applyAutoLayout(); + }; + + this.sidebar.addEventListener('transitionend', handleTransitionEnd); + + setTimeout(() => { + this.sidebar.removeEventListener('transitionend', handleTransitionEnd); + this.applyAutoLayout(); + }, 250); + } + + loadInitialData() { + // Extract project from URL (/projects/{project}) + const pathParts = window.location.pathname.split('/'); + if (pathParts.length >= 3) { + this.currentProject = pathParts[2]; + } + + // Initialize SSE status indicator + this.sseIndicator = new SSEStatusIndicator('sse-status'); + + // Initialize data service + this.dataService = new MetricsDataService(this.currentProject, { + onMetricUpdate: (metricName, runName, step, value) => { + if (!this.runSelector?.getSelectedRuns().has(runName)) { + return; + } + const chart = this.charts.get(metricName); + if (chart) { + chart.addDataPoint(runName, step, value); + } + this.sseIndicator?.recordEvent(); + }, + onStatusUpdate: (statusData) => this.handleStatusUpdate(statusData), + onCacheUpdated: () => this.renderMetricsFromCache(), + onConnectionStateChange: (state, detail) => { + if (!this.sseIndicator) return; + if (state === 'connected') { + this.sseIndicator.setConnected(); + } else if (state === 'reconnecting') { + this.sseIndicator.setReconnecting(detail.attempt ?? 0, detail.max ?? 0); + } else if (state === 'disconnected') { + this.sseIndicator.setDisconnected(detail.lastEventTime ?? 0); + } + }, + }); + + // Initialize run selector + this.runSelector = new RunSelector({ + onSelectionChange: (selectedRuns) => this.showMetrics(), + }); + + // Auto-load metrics if runs exist + if (this.runSelector.getSelectedRuns().size > 0) { + this.showMetrics(); + } else { + this.showInitialState(); + } + } + + showInitialState() { + this._setPageState('initial'); + this.clearAndRenderCharts({}); + this.runSelector.hideAllLegends(); + this.hideChartControls(); + } + + showLoadingState() { + this._setPageState('loading'); + } + + async showMetrics() { + const selectedRuns = this.runSelector.getSelectedRuns(); + + if (selectedRuns.size === 0) { + this.showInitialState(); + this.dataService.closeSSE(); + return; + } + + // Assign a fresh ID to this invocation so that any earlier in-flight + // showMetrics() call can detect it has been superseded and bail out + // before rendering or setting up SSE with stale data. + const requestId = ++this._metricsRequestId; + + const runsToFetch = this.dataService.getRunsToFetch(selectedRuns); + this.dataService.adjustCacheSize(selectedRuns.size); + + if (runsToFetch.length > 0) { + this.showLoadingState(); + try { + await this.dataService.fetchAndCacheMetrics(runsToFetch); + } catch (error) { + // Only surface the error if this is still the latest request; + // otherwise a newer selection change owns the UI state. + if (requestId === this._metricsRequestId) { + console.error('Error loading metrics:', error); + this.showErrorState(error.message); + } + return; + } + + // A newer showMetrics() invocation has superseded this one; discard + // the stale result to avoid overwriting fresh UI state. + if (requestId !== this._metricsRequestId) { + return; + } + } + + this.renderMetricsFromCache(); + } + + renderMetricsFromCache() { + const selectedRuns = this.runSelector.getSelectedRuns(); + if (selectedRuns.size === 0) { + this.showInitialState(); + return; + } + + const metricsData = this.dataService.getCachedMetrics(selectedRuns); + + if (!this.clearAndRenderCharts(metricsData)) { + this._setPageState('noData'); + this.hideChartControls(); + return; + } + + this._setPageState('metrics'); + this.updateRunColorLegends(); + } + + initializeChart(chartId, metricName, runData) { + const chart = new Chart(`#${chartId}`, { + onZoomChange: (zoomState) => { + if (this.syncZoom) { + this.globalZoomState = zoomState; + this.syncZoomToAllCharts(metricName); + } + }, + }); + + const chartData = convertToChartFormat(metricName, runData); + chartData.title = metricName; + + chart.setData(chartData); + + if (this.syncZoom && this.globalZoomState) { + chart.setExternalZoom(this.globalZoomState); + } + + return chart; + } + + syncZoomToAllCharts(excludeMetricName) { + for (const [metricName, chart] of this.charts.entries()) { + if (metricName !== excludeMetricName && chart.setExternalZoom) { + chart.setExternalZoom(this.globalZoomState); + } + } + } + + showErrorState(message) { + this._setPageState('noData'); + + const errorElement = this.noDataState.querySelector('p'); + if (errorElement) { + errorElement.textContent = `Error: ${message}`; + } + } + + updateRunColorLegends() { + const firstChart = this.charts.values().next().value; + if (!firstChart || !firstChart.getRunStyle) { + this.runSelector.hideAllLegends(); + return; + } + + this.runSelector.updateRunColorLegends((runName) => firstChart.getRunStyle(runName)); + } + + handleStatusUpdate(statusData) { + updateRunStatusIcon(statusData, '[SSE]'); + } + + /** + * Clean up all resources held by this page. + * Overrides BaseChartPage.destroy() to also clean up project-specific + * resources (dataService, runSelector, sseIndicator, event listeners). + */ + destroy() { + // Invalidate any in-flight showMetrics() request so its post-await + // rendering/SSE setup is skipped after we tear down resources below. + this._metricsRequestId++; + + // Clean up project-specific event listeners + if (this.syncZoomCheckbox && this.syncZoomHandler) { + this.syncZoomCheckbox.removeEventListener('change', this.syncZoomHandler); + this.syncZoomHandler = null; + } + if (this.toggleSidebarBtn && this.sidebarToggleHandler) { + this.toggleSidebarBtn.removeEventListener('click', this.sidebarToggleHandler); + this.sidebarToggleHandler = null; + } + + // Destroy owned child instances + if (this.dataService) { + this.dataService.destroy(); + this.dataService = null; + } + if (this.runSelector) { + this.runSelector.destroy(); + this.runSelector = null; + } + if (this.noteEditor?.destroy) { + this.noteEditor.destroy(); + } + this.noteEditor = null; + // SSEStatusIndicator holds no event listeners or async resources, + // but null the reference for completeness. + this.sseIndicator = null; + + // Destroy charts (delegates to Chart.destroy() for each) + for (const chart of this.charts.values()) { + if (chart.destroy) { + chart.destroy(); + } + } + + // Call parent cleanup (resize handlers, size buttons, charts map) + super.destroy(); + } +} + +// Initialize when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + const page = new ProjectDetail(); + // Store for SPA cleanup + window.__asparaPage = page; + registerPageLifecycle(page); +}); + +export { ProjectDetail }; diff --git a/src/aspara/dashboard/static/js/pages/run-detail.js b/src/aspara/dashboard/static/js/pages/run-detail.js new file mode 100644 index 0000000000000000000000000000000000000000..a5aa0b1adb341e55b178a76ef6378213b17d5a2b --- /dev/null +++ b/src/aspara/dashboard/static/js/pages/run-detail.js @@ -0,0 +1,129 @@ +/** + * Run detail page entry point. + * Orchestrates metric chart rendering for a single run with grid layout. + */ +import { Chart } from '../chart.js'; +import { registerPageLifecycle } from '../lifecycle.js'; +import { MetricsDataService } from '../metrics/metrics-data-service.js'; +import { initNoteEditorFromDOM } from '../note-editor.js'; +import { initializeTagEditorsForElements } from '../tag-editor.js'; +import { BaseChartPage } from './base-chart-page.js'; + +/** + * RunDetail manages the run detail page metrics visualization. + * Extends BaseChartPage for shared chart layout functionality. + */ +class RunDetail extends BaseChartPage { + constructor(project, run) { + super(); + this.project = project; + this.run = run; + this.dataService = new MetricsDataService(project); + this.chartsContainerId = 'metrics-container'; + this.emptyStateMessage = 'No metrics have been recorded for this run.'; + + // Editor instances (stored for cleanup in destroy()) + this.tagEditors = []; + this.noteEditor = null; + + this.init(); + this.initializeTagEditor(); + this.initializeNoteEditor(); + this.loadMetrics(); + } + + initializeTagEditor() { + this.tagEditors = initializeTagEditorsForElements('#run-tags-detail', (container) => { + const projectName = container.dataset.projectName; + const runName = container.dataset.runName; + if (!projectName || !runName) return null; + return `/api/projects/${projectName}/runs/${runName}/metadata`; + }); + } + + async initializeNoteEditor() { + this.noteEditor = await initNoteEditorFromDOM('run-note'); + } + + async loadMetrics() { + if (!this.chartsContainer) { + console.error('Metrics container not found'); + return; + } + + try { + await this.dataService.fetchAndCacheMetrics([this.run]); + const selectedRuns = new Set([this.run]); + const metricsData = this.dataService.getCachedMetrics(selectedRuns); + this.renderMetrics(metricsData); + } catch (error) { + console.error('Error loading metrics:', error); + this.showError(error.message); + } + } + + initializeChart(chartId, metricName, runData) { + const metricData = runData[this.run]; + if (!metricData?.steps?.length) { + return null; + } + + const chart = new Chart(`#${chartId}`); + chart.setData({ + series: [{ name: metricName, data: metricData }], + }); + return chart; + } + + /** + * Clean up all resources held by this page. + * Overrides BaseChartPage.destroy() to also clean up run-specific + * resources (dataService, tag editors, note editor). + */ + destroy() { + // Destroy owned child instances + if (this.dataService) { + this.dataService.destroy(); + this.dataService = null; + } + for (const editor of this.tagEditors) { + if (editor.destroy) { + editor.destroy(); + } + } + this.tagEditors = []; + if (this.noteEditor?.destroy) { + this.noteEditor.destroy(); + } + this.noteEditor = null; + + // Destroy charts (delegates to Chart.destroy() for each) + for (const chart of this.charts.values()) { + if (chart.destroy) { + chart.destroy(); + } + } + + // Call parent cleanup (resize handlers, size buttons, charts map) + super.destroy(); + } +} + +// Initialize when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + const detailRoot = document.getElementById('run-detail'); + const project = detailRoot?.dataset.project || window.runData?.project; + const run = detailRoot?.dataset.run || window.runData?.run; + + if (!project || !run) { + console.error('Run detail data not found'); + return; + } + + const page = new RunDetail(project, run); + // Store for SPA cleanup + window.__asparaPage = page; + registerPageLifecycle(page); +}); + +export { RunDetail }; diff --git a/src/aspara/dashboard/static/js/projects-list-utils.js b/src/aspara/dashboard/static/js/projects-list-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..306e8ee218855e314ee3ec776e08874e6c115ba3 --- /dev/null +++ b/src/aspara/dashboard/static/js/projects-list-utils.js @@ -0,0 +1,84 @@ +/** + * Pure utility functions for projects list + * These functions have no side effects and are easy to test + */ + +/** + * Check if a project matches the search query + * @param {Object} project - Project object with name and tags + * @param {string} query - Search query string + * @returns {boolean} True if project matches the query + */ +export function matchesSearch(project, query) { + if (!query) { + return true; + } + + const normalizedQuery = query.toLowerCase(); + + if (project.name.toLowerCase().includes(normalizedQuery)) { + return true; + } + + if (Array.isArray(project.tags)) { + for (const tag of project.tags) { + if (tag.toLowerCase().includes(normalizedQuery)) { + return true; + } + } + } + + return false; +} + +/** + * Create a sort comparator function for projects + * @param {string} sortKey - The key to sort by ('name', 'runCount', 'lastUpdate') + * @param {string} sortOrder - Sort order ('asc' or 'desc') + * @returns {Function} Comparator function for Array.sort() + */ +export function createSortComparator(sortKey, sortOrder) { + return (a, b) => { + let aVal; + let bVal; + + switch (sortKey) { + case 'name': + aVal = a.name.toLowerCase(); + bVal = b.name.toLowerCase(); + break; + case 'runCount': + aVal = a.runCount; + bVal = b.runCount; + break; + case 'lastUpdate': + aVal = a.lastUpdate; + bVal = b.lastUpdate; + break; + default: + return 0; + } + + if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1; + if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1; + return 0; + }; +} + +/** + * Parse a project DOM element into a data object + * @param {HTMLElement} element - DOM element with data attributes + * @returns {Object} Parsed project data + */ +export function parseProjectElement(element) { + return { + element, + name: element.dataset.project, + runCount: Number.parseInt(element.dataset.runCount) || 0, + lastUpdate: Number.parseInt(element.dataset.lastUpdate) || 0, + tags: (element.dataset.tags || '') + .split(' ') + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0), + }; +} diff --git a/src/aspara/dashboard/static/js/projects-list.js b/src/aspara/dashboard/static/js/projects-list.js new file mode 100644 index 0000000000000000000000000000000000000000..410c5f581331ba62daa405975c3183c18eb022ae --- /dev/null +++ b/src/aspara/dashboard/static/js/projects-list.js @@ -0,0 +1,394 @@ +import { deleteProjectApi } from './api/delete-api.js'; +import { registerPageLifecycle } from './lifecycle.js'; +import { createSortComparator, matchesSearch, parseProjectElement } from './projects-list-utils.js'; +import { initializeTagEditorsForElements } from './tag-editor.js'; +import { attachSortHeaders } from './sort-utils.js'; +import { debounce } from './timer-utils.js'; + +class ProjectsListSorter { + constructor() { + this.projects = []; + this.currentQuery = ''; + this.searchMode = 'realtime'; + this.sortKey = localStorage.getItem('projects_sort_key') || 'name'; + this.sortOrder = localStorage.getItem('projects_sort_order') || 'asc'; + + // Search input handler and debounced trigger (stored for cleanup) + this.searchInputHandler = null; + this._searchDebounced = null; + this._sortHeaderHandlers = []; + this._cardHandlers = []; + this._deleteContainerHandler = null; + + this.init(); + } + + init() { + this.loadProjects(); + this.setupSearch(); + this.attachEventListeners(); + this.updateSortIndicators(); + this.sortAndRender(); + this.initializeTagEditors(); + this.initializeCardNavigation(); + this.initializeDeleteHandlers(); + } + + /** + * Initialize tag editors for all projects + */ + initializeTagEditors() { + initializeTagEditorsForElements('[id^="project-tags-"]', (container) => { + const projectName = container.dataset.projectName; + if (!projectName) return null; + return `/api/projects/${projectName}/metadata`; + }); + } + + /** + * Initialize card navigation with proper event handling + * Prevents navigation when clicking on interactive elements like tag editors or buttons + */ + initializeCardNavigation() { + const projectCards = document.querySelectorAll('.project-card'); + for (const card of projectCards) { + const clickHandler = (e) => { + // Check if click is on a button or inside tag editor + const isButton = e.target.closest('button'); + const isTagEditor = e.target.closest('.tag-editor-wrapper'); + const isTagContainer = e.target.closest('[id^="project-tags-"]'); + + // Only navigate if not clicking on interactive elements + if (!isButton && !isTagEditor && !isTagContainer) { + this.navigateToProject(card); + } + }; + const keydownHandler = (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.navigateToProject(card); + } + }; + card.addEventListener('click', clickHandler); + card.addEventListener('keydown', keydownHandler); + this._cardHandlers.push({ element: card, handlers: [clickHandler, keydownHandler] }); + } + } + + /** + * Navigate to project detail page + * @param {HTMLElement} card - The project card element + */ + navigateToProject(card) { + const project = card.dataset.project; + window.location.href = `/projects/${encodeURIComponent(project)}`; + } + + loadProjects() { + const projectElements = document.querySelectorAll('.project-card[data-project]'); + this.projects = Array.from(projectElements).map(parseProjectElement); + } + + setupSearch() { + const root = document.getElementById('projects-root'); + if (root?.dataset.searchMode) { + this.searchMode = root.dataset.searchMode; + } + + const searchInput = document.getElementById('projectSearchInput'); + const searchButton = document.getElementById('projectSearchButton'); + + if (!searchInput) { + return; + } + + if (this.searchMode === 'realtime') { + if (searchButton) { + searchButton.style.display = 'none'; + } + + // Debounce re-rendering via the shared timer-utils (SSOT for + // search/filter debounce timing). + this._searchDebounced = debounce((value) => { + this.currentQuery = value.trim(); + this.sortAndRender(); + }); + this.searchInputHandler = (event) => { + this._searchDebounced(event.target.value); + }; + searchInput.addEventListener('input', this.searchInputHandler); + } else { + if (searchButton) { + searchButton.addEventListener('click', () => { + this.currentQuery = searchInput.value.trim(); + this.sortAndRender(); + }); + } + + searchInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + this.currentQuery = searchInput.value.trim(); + this.sortAndRender(); + } + }); + } + } + + attachEventListeners() { + this._sortHeaderHandlers = attachSortHeaders(this, 'projects'); + } + + updateSortIndicators() { + const headers = document.querySelectorAll('[data-sort]'); + for (const header of headers) { + const indicator = header.querySelector('.sort-indicator'); + if (header.dataset.sort === this.sortKey) { + indicator.textContent = this.sortOrder === 'asc' ? '↑' : '↓'; + indicator.classList.remove('text-text-muted'); + indicator.classList.add('text-text-primary'); + } else { + indicator.textContent = '↕'; + indicator.classList.remove('text-text-primary'); + indicator.classList.add('text-text-muted'); + } + } + } + + sortAndRender() { + this.projects.sort(createSortComparator(this.sortKey, this.sortOrder)); + + const container = document.getElementById('projects-container'); + if (container) { + for (const project of this.projects) { + project.element.remove(); + } + + for (const project of this.projects) { + if (matchesSearch(project, this.currentQuery)) { + container.appendChild(project.element); + } + } + } + } + + /** + * Initialize delete button handlers using event delegation + */ + initializeDeleteHandlers() { + const container = document.getElementById('projects-container'); + if (!container) return; + + this._deleteContainerHandler = async (e) => { + const deleteBtn = e.target.closest('.delete-project-btn'); + if (!deleteBtn) return; + + e.stopPropagation(); + const projectName = deleteBtn.dataset.project; + if (!projectName) return; + + await this.handleDeleteProject(projectName); + }; + container.addEventListener('click', this._deleteContainerHandler); + } + + /** + * Handle project deletion with confirmation and notification + * Uses optimistic UI: immediately removes card, restores on error + * @param {string} projectName - The project name to delete + */ + async handleDeleteProject(projectName) { + if (!projectName) { + window.showErrorNotification?.('Project name is not specified'); + return; + } + + const confirmed = await window.showConfirm({ + title: 'Delete Project', + message: `Are you sure you want to delete project "${projectName}"?\nThis action cannot be undone.`, + confirmText: 'Delete', + variant: 'danger', + dangerousAction: true, + }); + if (!confirmed) return; + + // Find the project and its card + const projectIndex = this.projects.findIndex((p) => p.name === projectName); + if (projectIndex === -1) { + window.showErrorNotification?.('Project not found'); + return; + } + + const project = this.projects[projectIndex]; + const card = project.element; + const nextSibling = card.nextElementSibling; + const parent = card.parentElement; + + // Save original styles for restoration + const originalStyles = { + height: card.offsetHeight, + marginTop: card.style.marginTop, + marginBottom: card.style.marginBottom, + paddingTop: card.style.paddingTop, + paddingBottom: card.style.paddingBottom, + opacity: card.style.opacity, + overflow: card.style.overflow, + border: card.style.border, + transition: card.style.transition, + }; + + // Remove from data array (optimistic) + this.projects.splice(projectIndex, 1); + + // Animate card collapse + await this.animateCardCollapse(card); + + try { + await deleteProjectApi(projectName); + // Success: remove card from DOM + card.remove(); + window.showSuccessNotification?.('Project has been deleted'); + } catch (error) { + console.error('Error deleting project:', error); + + // Restore to data array + this.projects.splice(projectIndex, 0, project); + + // Restore card with highlight animation + await this.restoreCard(card, nextSibling, parent, originalStyles); + + window.showErrorNotification?.(`Failed to delete project: ${error.message}`); + } + } + + /** + * Wait for specified milliseconds + * @param {number} ms - Milliseconds to wait + * @returns {Promise} + */ + wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Animate card collapse (fade out + shrink) + * @param {HTMLElement} card - The card element to collapse + */ + async animateCardCollapse(card) { + // Phase 1: Fade out + card.style.transition = 'opacity 150ms ease-out'; + card.style.opacity = '0'; + await this.wait(150); + + // Phase 2: Collapse height + card.style.overflow = 'hidden'; + card.style.transition = + 'height 200ms ease-out, margin-top 200ms ease-out, margin-bottom 200ms ease-out, padding-top 200ms ease-out, padding-bottom 200ms ease-out'; + card.style.height = `${card.offsetHeight}px`; // Set explicit height before animating + card.style.border = 'none'; + + // Force reflow + card.offsetHeight; + + card.style.height = '0'; + card.style.marginTop = '0'; + card.style.marginBottom = '0'; + card.style.paddingTop = '0'; + card.style.paddingBottom = '0'; + + await this.wait(200); + } + + /** + * Restore card with highlight animation + * @param {HTMLElement} card - The card element to restore + * @param {HTMLElement|null} nextSibling - The next sibling element + * @param {HTMLElement} parent - The parent container + * @param {Object} originalStyles - Original style values + */ + async restoreCard(card, nextSibling, parent, originalStyles) { + // Re-insert card in correct position if needed + if (!card.parentElement) { + if (nextSibling) { + parent.insertBefore(card, nextSibling); + } else { + parent.appendChild(card); + } + } + + // Phase 1: Expand height + card.style.transition = + 'height 200ms ease-out, margin-top 200ms ease-out, margin-bottom 200ms ease-out, padding-top 200ms ease-out, padding-bottom 200ms ease-out'; + card.style.height = `${originalStyles.height}px`; + card.style.marginTop = originalStyles.marginTop || ''; + card.style.marginBottom = originalStyles.marginBottom || ''; + card.style.paddingTop = originalStyles.paddingTop || ''; + card.style.paddingBottom = originalStyles.paddingBottom || ''; + + await this.wait(200); + + // Phase 2: Fade in with red highlight + card.style.transition = 'opacity 200ms ease-out'; + card.style.opacity = '1'; + card.style.boxShadow = '0 0 0 2px rgba(239, 68, 68, 0.7)'; + + await this.wait(200); + + // Phase 3: Fade out highlight + card.style.transition = 'box-shadow 1000ms ease-out'; + card.style.boxShadow = ''; + + await this.wait(1000); + + // Clean up styles + card.style.height = ''; + card.style.overflow = originalStyles.overflow || ''; + card.style.border = originalStyles.border || ''; + card.style.transition = originalStyles.transition || ''; + } + + /** + * Clean up event listeners and timeouts. + */ + destroy() { + if (this._searchDebounced) { + this._searchDebounced.cancel(); + this._searchDebounced = null; + } + if (this.searchInputHandler) { + const searchInput = document.getElementById('projectSearchInput'); + if (searchInput) { + searchInput.removeEventListener('input', this.searchInputHandler); + } + this.searchInputHandler = null; + } + for (const { element, handler } of this._sortHeaderHandlers) { + element.removeEventListener('click', handler); + } + this._sortHeaderHandlers = []; + for (const { element, handlers } of this._cardHandlers) { + for (const handler of handlers) { + element.removeEventListener('click', handler); + element.removeEventListener('keydown', handler); + } + } + this._cardHandlers = []; + if (this._deleteContainerHandler) { + const container = document.getElementById('projects-container'); + if (container) { + container.removeEventListener('click', this._deleteContainerHandler); + } + this._deleteContainerHandler = null; + } + } +} + +document.addEventListener('DOMContentLoaded', () => { + if (document.getElementById('projects-container')) { + const page = new ProjectsListSorter(); + window.__asparaPage = page; + registerPageLifecycle(page); + } +}); + +export { ProjectsListSorter }; diff --git a/src/aspara/dashboard/static/js/read-only-guard.js b/src/aspara/dashboard/static/js/read-only-guard.js new file mode 100644 index 0000000000000000000000000000000000000000..36928361f757aa9f49f06f4d7be05be5c2d4a79d --- /dev/null +++ b/src/aspara/dashboard/static/js/read-only-guard.js @@ -0,0 +1,16 @@ +/** + * Read-only mode guard + * Shows a dialog when the user tries to edit in read-only mode. + */ + +const isReadOnly = document.body.hasAttribute('data-read-only'); + +/** + * Check if read-only mode is active and show dialog if so. + * @returns {boolean} true if read-only mode is active (action should be blocked) + */ +export function guardReadOnly() { + if (!isReadOnly) return false; + document.getElementById('read-only-dialog')?.showModal(); + return true; +} diff --git a/src/aspara/dashboard/static/js/runs-list/index.js b/src/aspara/dashboard/static/js/runs-list/index.js new file mode 100644 index 0000000000000000000000000000000000000000..d64c9e3595426cc86711637da3fe3b6d71e4151f --- /dev/null +++ b/src/aspara/dashboard/static/js/runs-list/index.js @@ -0,0 +1,197 @@ +import { deleteRunApi } from '../api/delete-api.js'; +import { registerPageLifecycle } from '../lifecycle.js'; +import { initializeTagEditorsForElements } from '../tag-editor.js'; +import { attachSortHeaders } from '../sort-utils.js'; +import { createRunSortComparator, parseRunElement } from './utils.js'; + +class RunsListSorter { + constructor() { + this.runs = []; + this.sortKey = localStorage.getItem('runs_sort_key') || 'name'; + this.sortOrder = localStorage.getItem('runs_sort_order') || 'asc'; + this._abortController = new AbortController(); + this.init(); + } + + init() { + this.loadRuns(); + this.attachEventListeners(); + this.updateSortIndicators(); + this.sortAndRender(); + this.initializeTagEditors(); + this.initializeCardNavigation(); + this.initializeDeleteHandlers(); + } + + /** + * Remove all event listeners and clean up. + * Call this when the sorter is no longer needed to prevent memory leaks. + */ + destroy() { + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + } + } + + /** + * Initialize tag editors for all runs + */ + initializeTagEditors() { + initializeTagEditorsForElements('[id^="run-tags-"]', (container) => { + const projectName = container.dataset.projectName; + const runName = container.dataset.runName; + if (!projectName || !runName) return null; + return `/api/projects/${projectName}/runs/${runName}/metadata`; + }); + } + + /** + * Initialize card navigation with proper event handling + * Prevents navigation when clicking on interactive elements like tag editors or buttons + */ + initializeCardNavigation() { + const signal = this._abortController.signal; + const runCards = document.querySelectorAll('.run-card'); + for (const card of runCards) { + card.addEventListener( + 'click', + (e) => { + // Check if click is on a button or inside tag editor + const isButton = e.target.closest('button'); + const isTagEditor = e.target.closest('.tag-editor-wrapper'); + const isTagContainer = e.target.closest('[id^="run-tags-"]'); + + // Only navigate if not clicking on interactive elements + if (!isButton && !isTagEditor && !isTagContainer) { + this.navigateToRun(card); + } + }, + { signal } + ); + + // Keyboard navigation: Enter or Space to activate + card.addEventListener( + 'keydown', + (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.navigateToRun(card); + } + }, + { signal } + ); + } + } + + /** + * Navigate to run detail page + * @param {HTMLElement} card - The run card element + */ + navigateToRun(card) { + const project = card.dataset.project; + const run = card.dataset.run; + window.location.href = `/projects/${encodeURIComponent(project)}/runs/${encodeURIComponent(run)}`; + } + + loadRuns() { + const runElements = document.querySelectorAll('.run-card[data-run]'); + this.runs = Array.from(runElements).map(parseRunElement); + } + + attachEventListeners() { + attachSortHeaders(this, 'runs', this._abortController.signal); + } + + updateSortIndicators() { + const headers = document.querySelectorAll('[data-sort]'); + for (const header of headers) { + const indicator = header.querySelector('.sort-indicator'); + if (header.dataset.sort === this.sortKey) { + indicator.textContent = this.sortOrder === 'asc' ? '↑' : '↓'; + indicator.classList.remove('text-text-muted'); + indicator.classList.add('text-text-primary'); + } else { + indicator.textContent = '↕'; + indicator.classList.remove('text-text-primary'); + indicator.classList.add('text-text-muted'); + } + } + } + + sortAndRender() { + this.runs.sort(createRunSortComparator(this.sortKey, this.sortOrder)); + + const container = document.getElementById('runs-container'); + if (container) { + for (const run of this.runs) { + container.appendChild(run.element); + } + } + } + + /** + * Initialize delete button handlers using event delegation + */ + initializeDeleteHandlers() { + const container = document.getElementById('runs-container'); + if (!container) return; + + const signal = this._abortController.signal; + container.addEventListener( + 'click', + async (e) => { + const deleteBtn = e.target.closest('.delete-run-btn'); + if (!deleteBtn) return; + + e.stopPropagation(); + const projectName = deleteBtn.dataset.project; + const runName = deleteBtn.dataset.run; + if (!projectName || !runName) return; + + await this.handleDeleteRun(projectName, runName); + }, + { signal } + ); + } + + /** + * Handle run deletion with confirmation and notification + * @param {string} projectName - The project name + * @param {string} runName - The run name to delete + */ + async handleDeleteRun(projectName, runName) { + if (!projectName || !runName) { + window.showErrorNotification?.('Project name or run name is not specified'); + return; + } + + const confirmed = await window.showConfirm({ + title: 'Delete Run', + message: `Are you sure you want to delete run "${runName}"?\nThis action cannot be undone.`, + confirmText: 'Delete', + variant: 'danger', + dangerousAction: true, + }); + if (!confirmed) return; + + try { + const data = await deleteRunApi(projectName, runName); + window.showSuccessNotification?.(data.message || 'Run has been deleted'); + setTimeout(() => window.location.reload(), 1000); + } catch (error) { + console.error('Error deleting run:', error); + window.showErrorNotification?.(`Failed to delete: ${error.message}`); + } + } +} + +document.addEventListener('DOMContentLoaded', () => { + if (document.getElementById('runs-container')) { + const page = new RunsListSorter(); + window.__asparaPage = page; + registerPageLifecycle(page); + } +}); + +export { RunsListSorter }; diff --git a/src/aspara/dashboard/static/js/runs-list/sse-utils.js b/src/aspara/dashboard/static/js/runs-list/sse-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..7833faaf3931dfa332ec8249e9b216a054eabd8c --- /dev/null +++ b/src/aspara/dashboard/static/js/runs-list/sse-utils.js @@ -0,0 +1,132 @@ +/** + * Pure utility functions for runs list SSE + * These functions have no side effects and are easy to test + */ + +/** + * Initial timestamp for first SSE connection (epoch) + * Using epoch ensures all existing data is fetched on first connection + * Value is UNIX time in milliseconds + */ +export const INITIAL_SINCE_TIMESTAMP = 0; + +/** + * Build SSE URL for runs status stream + * @param {string} project - Project name + * @param {Array} runs - Array of run names + * @param {number} since - UNIX timestamp in milliseconds to filter metrics from (required) + * @returns {string} SSE URL + */ +export function buildSSEUrl(project, runs, since) { + const runsList = runs.join(','); + return `/api/projects/${encodeURIComponent(project)}/runs/stream?runs=${encodeURIComponent(runsList)}&since=${since}`; +} + +/** + * Valid run status values + */ +const VALID_STATUSES = ['wip', 'completed', 'failed', 'maybe_failed']; + +/** + * Mapping from status to icon ID + * Most statuses use the pattern status-icon-{status}, but some use shared icons + */ +const STATUS_ICON_MAP = { + wip: 'status-icon-wip', + completed: 'status-icon-completed', + failed: 'status-icon-failed', + maybe_failed: 'icon-exclamation-triangle', +}; + +/** + * Parse status update from SSE event data + * @param {string} eventData - JSON string from SSE event + * @returns {Object|null} Parsed status data, or null if parsing or validation fails + */ +export function parseStatusUpdate(eventData) { + try { + const data = JSON.parse(eventData); + + // Validate required fields and types + if (typeof data !== 'object' || data === null) { + console.error('[SSE] Invalid status update: not an object'); + return null; + } + + if (typeof data.run !== 'string' || data.run.length === 0) { + console.error('[SSE] Invalid status update: missing or invalid run field'); + return null; + } + + if (typeof data.status !== 'string' || !VALID_STATUSES.includes(data.status)) { + console.error('[SSE] Invalid status update: invalid status value:', data.status); + return null; + } + + return data; + } catch (e) { + console.error('[SSE] Failed to parse status update:', e); + return null; + } +} + +/** + * Create icon update attributes from status data. + * CSS styling is handled by the [data-status] attribute selector in CSS. + * @param {Object} statusData - Status data with status (must be validated by parseStatusUpdate first) + * @returns {Object} Icon update info with innerHTML, status + */ +export function createIconUpdateFromStatus(statusData) { + // Validate status against whitelist (defense in depth) + const status = VALID_STATUSES.includes(statusData.status) ? statusData.status : 'wip'; + const iconId = STATUS_ICON_MAP[status]; + return { + innerHTML: ``, + status: status, + }; +} + +/** + * Extract run names from DOM elements + * @param {NodeList|Array} elements - Elements with data-run attribute + * @returns {Array} Array of run names + */ +export function extractRunNamesFromElements(elements) { + return Array.from(elements).map((el) => el.dataset.run); +} + +/** + * Check if EventSource connection is closed + * @param {number} readyState - EventSource readyState + * @returns {boolean} True if connection is closed + */ +export function isConnectionClosed(readyState) { + // EventSource.CLOSED = 2 + return readyState === 2; +} + +/** + * Update run status icon in the DOM. + * CSS styling is handled by the [data-status] attribute selector in CSS. + * @param {Object} statusData - Status data with run, status + * @param {string} logPrefix - Prefix for console logs (e.g., '[SSE]', '[RunsListSSE]') + * @returns {boolean} True if update was successful, false if container not found + */ +export function updateRunStatusIcon(statusData, logPrefix = '[SSE]') { + console.log(`${logPrefix} Status update:`, statusData); + + const runName = statusData.run; + const container = document.querySelector(`[data-run-status-icon="${runName}"]`); + + if (container) { + const update = createIconUpdateFromStatus(statusData); + container.setAttribute('data-status', update.status); + container.innerHTML = update.innerHTML; + + console.log(`${logPrefix} Updated status for run:`, runName); + return true; + } + + console.warn(`${logPrefix} Could not find status icon container for run:`, runName); + return false; +} diff --git a/src/aspara/dashboard/static/js/runs-list/sse.js b/src/aspara/dashboard/static/js/runs-list/sse.js new file mode 100644 index 0000000000000000000000000000000000000000..f14567fc2482e9bb599b1e81255a817d5a827056 --- /dev/null +++ b/src/aspara/dashboard/static/js/runs-list/sse.js @@ -0,0 +1,168 @@ +/** + * SSE Status Updates for Runs List Page + * Handles real-time status updates for runs displayed in the runs list + */ + +import { removeEventListeners } from '../html-utils.js'; +import { SSEReconnectManager } from '../sse-reconnect-manager.js'; +import { INITIAL_SINCE_TIMESTAMP, buildSSEUrl, extractRunNamesFromElements, isConnectionClosed, parseStatusUpdate, updateRunStatusIcon } from './sse-utils.js'; + +class RunsListSSE { + constructor(project, options = {}) { + this.project = project; + this.eventSource = null; + this.lastTimestamp = INITIAL_SINCE_TIMESTAMP; + this.lastEventTime = 0; + this.runs = []; + + // Reconnection state managed by SSEReconnectManager (SSOT for + // backoff constants, guard logic, and connection state notification). + this.reconnectManager = new SSEReconnectManager({ + onConnectionStateChange: options.onConnectionStateChange || null, + }); + + // SSE event handlers (stored for cleanup) + this.sseOpenHandler = null; + this.sseStatusHandler = null; + this.sseMetricHandler = null; + this.sseErrorHandler = null; + + this.setupSSE(); + } + + setupSSE() { + const runElements = document.querySelectorAll('[data-run]'); + if (runElements.length === 0) { + console.log('[RunsListSSE] No runs found on page'); + return; + } + + this.runs = extractRunNamesFromElements(runElements); + const sseUrl = buildSSEUrl(this.project, this.runs, this.lastTimestamp); + console.log('[RunsListSSE] Connecting to SSE:', sseUrl); + + this.eventSource = new EventSource(sseUrl); + + // Store handlers as member variables for cleanup + this.sseOpenHandler = () => { + console.log('[RunsListSSE] SSE connection opened with since:', this.lastTimestamp); + this.reconnectManager.resetReconnectState(); + this.reconnectManager.notifyConnectionState('connected'); + // Note: Don't update lastTimestamp here - it's updated when receiving events + // This ensures we don't miss data between the last event and reconnection + }; + + this.sseStatusHandler = (event) => { + const statusData = parseStatusUpdate(event.data); + if (statusData === null) { + return; // Skip invalid data + } + this.handleStatusUpdate(statusData); + this.lastEventTime = Date.now(); + // Update lastTimestamp if the event has a timestamp + if (statusData.timestamp) { + this.lastTimestamp = statusData.timestamp; + } + }; + + this.sseMetricHandler = (event) => { + let metricData; + try { + metricData = JSON.parse(event.data); + } catch (error) { + console.error('[RunsListSSE] Error parsing metric JSON:', error); + return; + } + this.lastEventTime = Date.now(); + // Update lastTimestamp if the event has a timestamp + if (metricData?.timestamp) { + this.lastTimestamp = metricData.timestamp; + } + }; + + this.sseErrorHandler = (event) => { + if (isConnectionClosed(this.eventSource.readyState)) { + console.log('[RunsListSSE] SSE connection closed, attempting reconnect with since:', this.lastTimestamp); + // Connection is closed, reconnect with updated timestamp + this.reconnect(); + } else { + console.error('[RunsListSSE] SSE connection error:', event); + } + }; + + this.eventSource.addEventListener('open', this.sseOpenHandler); + this.eventSource.addEventListener('status', this.sseStatusHandler); + this.eventSource.addEventListener('metric', this.sseMetricHandler); + this.eventSource.addEventListener('error', this.sseErrorHandler); + } + + /** + * Reconnect SSE with updated since timestamp. + * Uses exponential backoff (1s, 2s, 4s, 8s... max 30s) and a max retry + * limit to prevent reconnection storms when the server is down. + */ + reconnect() { + if (!this.reconnectManager.canReconnect()) { + if (!this.reconnectManager.isReconnecting && this.reconnectManager.reconnectAttempts >= this.reconnectManager.maxReconnectAttempts) { + console.error('[RunsListSSE] Max reconnection attempts reached, giving up'); + } + return; + } + + this.reconnectManager.beginReconnect(); + + // Exponential backoff via SSEReconnectManager + const delay = this.reconnectManager.calculateBackoffDelay(this.reconnectManager.reconnectAttempts); + console.log(`[RunsListSSE] Reconnect attempt ${this.reconnectManager.reconnectAttempts}/${this.reconnectManager.maxReconnectAttempts}, waiting ${delay}ms`); + + // Close existing connection if any. + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + + setTimeout(() => { + if (this.runs.length > 0) { + console.log('[RunsListSSE] Reconnecting SSE with since:', this.lastTimestamp); + this.setupSSE(); + } + // isReconnecting is reset in the 'open' handler on successful + // connection. If runs is empty, reset here to avoid getting stuck. + if (this.runs.length === 0) { + this.reconnectManager.isReconnecting = false; + } + }, delay); + } + + handleStatusUpdate(statusData) { + updateRunStatusIcon(statusData, '[RunsListSSE]'); + } + + /** + * Destroy the SSE connection and release all resources. + * Implements the Destroyable lifecycle contract. + */ + destroy() { + if (this.eventSource) { + removeEventListeners(this.eventSource, { + open: this.sseOpenHandler, + status: this.sseStatusHandler, + metric: this.sseMetricHandler, + error: this.sseErrorHandler, + }); + this.eventSource.close(); + this.eventSource = null; + console.log('[RunsListSSE] SSE connection closed'); + } + this.sseOpenHandler = null; + this.sseStatusHandler = null; + this.sseMetricHandler = null; + this.sseErrorHandler = null; + // Reset reconnection state so a future re-init starts fresh. + this.reconnectManager.resetReconnectState(); + } +} + +window.RunsListSSE = RunsListSSE; + +export { RunsListSSE }; diff --git a/src/aspara/dashboard/static/js/runs-list/utils.js b/src/aspara/dashboard/static/js/runs-list/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..3653fe36701c938f2b18e452320893b57a2f90f3 --- /dev/null +++ b/src/aspara/dashboard/static/js/runs-list/utils.js @@ -0,0 +1,52 @@ +/** + * Pure utility functions for runs list + * These functions have no side effects and are easy to test + */ + +/** + * Create a sort comparator function for runs + * @param {string} sortKey - The key to sort by ('name', 'metricCount', 'paramCount', 'lastUpdate') + * @param {string} sortOrder - Sort order ('asc' or 'desc') + * @returns {Function} Comparator function for Array.sort() + */ +export function createRunSortComparator(sortKey, sortOrder) { + return (a, b) => { + let aVal; + let bVal; + + switch (sortKey) { + case 'name': + aVal = a.name.toLowerCase(); + bVal = b.name.toLowerCase(); + break; + case 'paramCount': + aVal = a.paramCount; + bVal = b.paramCount; + break; + case 'lastUpdate': + aVal = a.lastUpdate; + bVal = b.lastUpdate; + break; + default: + return 0; + } + + if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1; + if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1; + return 0; + }; +} + +/** + * Parse a run DOM element into a data object + * @param {HTMLElement} element - DOM element with data attributes + * @returns {Object} Parsed run data + */ +export function parseRunElement(element) { + return { + element, + name: element.dataset.run, + paramCount: Number.parseInt(element.dataset.paramCount) || 0, + lastUpdate: Number.parseInt(element.dataset.lastUpdate) || 0, + }; +} diff --git a/src/aspara/dashboard/static/js/settings-menu.js b/src/aspara/dashboard/static/js/settings-menu.js new file mode 100644 index 0000000000000000000000000000000000000000..697666aa48a5dc5ee8baf0c2d15c12b42897a6e3 --- /dev/null +++ b/src/aspara/dashboard/static/js/settings-menu.js @@ -0,0 +1,158 @@ +/** + * Settings Menu JavaScript module + * Handles hamburger menu interactions and settings toggles + */ +import { registerPageLifecycle } from './lifecycle.js'; + +const FULL_WIDTH_KEY = 'aspara-full-width'; + +class SettingsMenu { + constructor() { + this.menuButton = document.getElementById('settings-menu-button'); + this.menuDropdown = document.getElementById('settings-menu-dropdown'); + this.fullWidthToggle = document.getElementById('fullWidthToggle'); + this.mainContainer = document.getElementById('main-content-container'); + this.navContainer = document.getElementById('nav-container'); + + if (!this.menuButton || !this.menuDropdown) { + return; + } + + this.isOpen = false; + + // Document event handlers (stored for cleanup) + this.documentClickHandler = null; + this.documentKeydownHandler = null; + + this.setupEventListeners(); + this.restoreSettings(); + } + + setupEventListeners() { + // Toggle menu on button click + this.menuButton.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleMenu(); + }); + + // Close menu when clicking outside (stored for cleanup) + this.documentClickHandler = (e) => { + if (this.isOpen && !this.menuDropdown.contains(e.target)) { + this.closeMenu(); + } + }; + document.addEventListener('click', this.documentClickHandler); + + // Close menu on Escape key (stored for cleanup) + this.documentKeydownHandler = (e) => { + if (e.key === 'Escape' && this.isOpen) { + this.closeMenu(); + } + }; + document.addEventListener('keydown', this.documentKeydownHandler); + + // Full Width toggle + if (this.fullWidthToggle) { + this.fullWidthToggle.addEventListener('change', () => { + this.handleFullWidthToggle(); + }); + } + } + + toggleMenu() { + if (this.isOpen) { + this.closeMenu(); + } else { + this.openMenu(); + } + } + + openMenu() { + this.menuDropdown.classList.remove('opacity-0', 'scale-95', 'pointer-events-none'); + this.menuDropdown.classList.add('opacity-100', 'scale-100'); + this.isOpen = true; + } + + closeMenu() { + this.menuDropdown.classList.remove('opacity-100', 'scale-100'); + this.menuDropdown.classList.add('opacity-0', 'scale-95', 'pointer-events-none'); + this.isOpen = false; + } + + restoreSettings() { + // Restore Full Width setting + const isFullWidth = localStorage.getItem(FULL_WIDTH_KEY) === 'true'; + if (this.fullWidthToggle) { + this.fullWidthToggle.checked = isFullWidth; + } + if (isFullWidth) { + this.enableFullWidth(); + } + } + + handleFullWidthToggle() { + const isEnabled = this.fullWidthToggle.checked; + localStorage.setItem(FULL_WIDTH_KEY, isEnabled); + + if (isEnabled) { + this.enableFullWidth(); + } else { + this.disableFullWidth(); + } + + // Dispatch custom event for other modules to listen to + window.dispatchEvent( + new CustomEvent('fullWidthChanged', { + detail: { enabled: isEnabled }, + }) + ); + + // Close menu after toggle + this.closeMenu(); + } + + enableFullWidth() { + if (this.mainContainer) { + this.mainContainer.classList.remove('max-w-7xl'); + this.mainContainer.classList.add('max-w-full', 'px-8'); + } + if (this.navContainer) { + this.navContainer.classList.remove('max-w-7xl'); + this.navContainer.classList.add('max-w-full', 'px-8'); + } + } + + disableFullWidth() { + if (this.mainContainer) { + this.mainContainer.classList.remove('max-w-full', 'px-8'); + this.mainContainer.classList.add('max-w-7xl'); + } + if (this.navContainer) { + this.navContainer.classList.remove('max-w-full', 'px-8'); + this.navContainer.classList.add('max-w-7xl'); + } + } + + /** + * Clean up event listeners. + */ + destroy() { + if (this.documentClickHandler) { + document.removeEventListener('click', this.documentClickHandler); + this.documentClickHandler = null; + } + if (this.documentKeydownHandler) { + document.removeEventListener('keydown', this.documentKeydownHandler); + this.documentKeydownHandler = null; + } + } +} + +// Initialize when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + const page = new SettingsMenu(); + window.__asparaPage = page; + registerPageLifecycle(page); +}); + +export { SettingsMenu }; diff --git a/src/aspara/dashboard/static/js/sort-utils.js b/src/aspara/dashboard/static/js/sort-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..7cee87613389e247f7976fcee51f4a1e792ef071 --- /dev/null +++ b/src/aspara/dashboard/static/js/sort-utils.js @@ -0,0 +1,38 @@ +/** + * Shared sort header listener utilities for list pages. + */ + +/** + * Attach click listeners to all [data-sort] headers and update sort state. + * + * The instance is expected to expose `sortKey`, `sortOrder`, + * `updateSortIndicators()`, and `sortAndRender()`. + * + * @param {Object} instance - The list sorter instance + * @param {string} storagePrefix - Prefix for localStorage keys (e.g. 'projects') + * @param {AbortSignal|null} signal - Optional AbortSignal for automatic cleanup + * @returns {Array<{element: HTMLElement, handler: function}>} Registered handlers + */ +export function attachSortHeaders(instance, storagePrefix, signal = null) { + const handlers = []; + const headers = document.querySelectorAll('[data-sort]'); + for (const header of headers) { + const handler = () => { + const key = header.dataset.sort; + if (instance.sortKey === key) { + instance.sortOrder = instance.sortOrder === 'asc' ? 'desc' : 'asc'; + } else { + instance.sortKey = key; + instance.sortOrder = 'asc'; + } + localStorage.setItem(`${storagePrefix}_sort_key`, instance.sortKey); + localStorage.setItem(`${storagePrefix}_sort_order`, instance.sortOrder); + instance.updateSortIndicators(); + instance.sortAndRender(); + }; + const options = signal ? { signal } : {}; + header.addEventListener('click', handler, options); + handlers.push({ element: header, handler }); + } + return handlers; +} diff --git a/src/aspara/dashboard/static/js/sse-reconnect-manager.js b/src/aspara/dashboard/static/js/sse-reconnect-manager.js new file mode 100644 index 0000000000000000000000000000000000000000..b5ac68d0ad6877108364c3de04077c29b0ac4378 --- /dev/null +++ b/src/aspara/dashboard/static/js/sse-reconnect-manager.js @@ -0,0 +1,114 @@ +/** + * SSE reconnection state manager. + * + * Single source of truth for reconnection constants, exponential backoff + * calculation, reconnection guard logic, and connection state notification. + * + * Used by both MetricsDataService and RunsListSSE to ensure consistent + * reconnection behavior across all SSE consumers. + */ + +/** + * @typedef {'connected' | 'reconnecting' | 'disconnected'} ConnectionState + */ + +/** + * @typedef {Object} SSEReconnectManagerOptions + * @property {function(ConnectionState, Object): void} [onConnectionStateChange] + * Callback invoked when the connection state changes. + * @property {number} [maxReconnectAttempts] + * Maximum number of reconnection attempts before giving up. + * @property {number} [baseReconnectDelay] + * Base delay in milliseconds for the first reconnection attempt. + * @property {number} [maxReconnectDelay] + * Upper bound for the backoff delay in milliseconds. + */ + +export class SSEReconnectManager { + /** @type {number} Maximum reconnection attempts before giving up. */ + static MAX_RECONNECT_ATTEMPTS = 10; + + /** @type {number} Base delay (ms) for the first reconnection attempt. */ + static BASE_RECONNECT_DELAY_MS = 1000; + + /** @type {number} Upper bound for the backoff delay (ms). */ + static MAX_RECONNECT_DELAY_MS = 30000; + + /** + * @param {SSEReconnectManagerOptions} [options] + */ + constructor(options = {}) { + this.onConnectionStateChange = options.onConnectionStateChange || null; + this.maxReconnectAttempts = options.maxReconnectAttempts ?? SSEReconnectManager.MAX_RECONNECT_ATTEMPTS; + this.baseReconnectDelay = options.baseReconnectDelay ?? SSEReconnectManager.BASE_RECONNECT_DELAY_MS; + this.maxReconnectDelay = options.maxReconnectDelay ?? SSEReconnectManager.MAX_RECONNECT_DELAY_MS; + + this.isReconnecting = false; + this.reconnectAttempts = 0; + } + + /** + * Calculate exponential backoff delay for the given attempt number. + * + * @param {number} attempt - 1-based attempt number + * @returns {number} Delay in milliseconds + */ + calculateBackoffDelay(attempt) { + return Math.min(this.baseReconnectDelay * 2 ** (attempt - 1), this.maxReconnectDelay); + } + + /** + * Check whether a new reconnection attempt is allowed. + * + * Returns false if a reconnection is already in progress or if the + * maximum number of attempts has been reached. The caller is + * responsible for emitting a 'disconnected' notification with + * appropriate detail when the max is reached. + * + * @returns {boolean} True if a new reconnection attempt may proceed. + */ + canReconnect() { + if (this.isReconnecting) { + return false; + } + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + return false; + } + return true; + } + + /** + * Begin a reconnection attempt. + * + * Sets `isReconnecting` to true, increments `reconnectAttempts`, and + * emits a 'reconnecting' state notification with attempt details. + */ + beginReconnect() { + this.isReconnecting = true; + this.reconnectAttempts++; + this.notifyConnectionState('reconnecting', { + attempt: this.reconnectAttempts, + max: this.maxReconnectAttempts, + }); + } + + /** + * Reset reconnection state after a successful connection. + */ + resetReconnectState() { + this.isReconnecting = false; + this.reconnectAttempts = 0; + } + + /** + * Notify a connection state change via the registered callback. + * + * @param {ConnectionState} state + * @param {Object} [detail={}] - Additional context + */ + notifyConnectionState(state, detail = {}) { + if (this.onConnectionStateChange) { + this.onConnectionStateChange(state, detail); + } + } +} diff --git a/src/aspara/dashboard/static/js/status-utils.js b/src/aspara/dashboard/static/js/status-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..c9506b4fdd87d7d8ed8661b611d4406a5c424009 --- /dev/null +++ b/src/aspara/dashboard/static/js/status-utils.js @@ -0,0 +1,25 @@ +/** + * Status utility functions for Aspara Dashboard. + * + * Provides display name mapping and status-related utilities. + */ + +/** + * Status display name mapping. + * Maps status values to human-readable display names. + */ +export const STATUS_DISPLAY_NAMES = { + wip: 'Running', + completed: 'Completed', + failed: 'Failed', + maybe_failed: 'Maybe Failed', +}; + +/** + * Get display name for a status value. + * @param {string} status - Status value (wip, completed, failed, maybe_failed) + * @returns {string} Human-readable display name + */ +export function getStatusDisplayName(status) { + return STATUS_DISPLAY_NAMES[status] || status; +} diff --git a/src/aspara/dashboard/static/js/tag-editor.js b/src/aspara/dashboard/static/js/tag-editor.js new file mode 100644 index 0000000000000000000000000000000000000000..592c4216799da237343533a8980a0e80b65e53af --- /dev/null +++ b/src/aspara/dashboard/static/js/tag-editor.js @@ -0,0 +1,401 @@ +/** + * Tag editor using @jcubic/tagger library + */ + +import tagger from '@jcubic/tagger'; +import { isDev } from './dev-mode.js'; +import { ICON_EDIT, escapeHtml } from './html-utils.js'; +import { guardReadOnly } from './read-only-guard.js'; + +class TagEditor { + constructor() { + this.taggerInstance = null; + this.isEditing = false; + this._abortController = null; + } + + /** + * Parse a comma-separated tag string into a clean array of tags. + * @param {string} value - Comma-separated tag string + * @returns {Array} Array of trimmed, non-empty tags + */ + static parseTags(value) { + return value + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + } + + /** + * Render tag chips as HTML. + * @param {Array} tags - Tags to render + * @returns {string} HTML string of tag chips + */ + static renderTagsHtml(tags) { + return tags + .map( + (tag) => + `${escapeHtml(tag)}` + ) + .join(''); + } + + /** + * Initialize tag editor for an element + * @param {HTMLElement} element - The tag display element + * @param {string} apiEndpoint - API endpoint for updating tags + * @param {Array} currentTags - Current tag list + */ + init(element, apiEndpoint, currentTags = []) { + if (!element || !apiEndpoint) return; + + this._abortController = new AbortController(); + const { wrapper, editBtn, input, saveBtn, cancelBtn } = this.createTagWrapper(element, currentTags); + element.parentNode.replaceChild(wrapper, element); + + // Set initial tags BEFORE initializing tagger + // This is important because tagger needs to read the initial value on initialization + input.value = currentTags.join(','); + + // Initialize tagger on input (after setting value) + this.taggerInstance = tagger(input, { + allow_duplicates: false, + allow_spaces: false, + wrap: true, + }); + + // Apply custom styles to tagger container for smaller font size and wider width + const taggerContainer = input.parentElement; + if (taggerContainer?.classList.contains('tagger')) { + taggerContainer.style.fontSize = '0.75rem'; // 12px equivalent + taggerContainer.style.minHeight = '1.5rem'; // Smaller minimum height for smaller font + taggerContainer.style.width = '50em'; // Set explicit width for tagger container + taggerContainer.style.maxWidth = '100%'; // Don't overflow parent + } + + this.attachEventListeners(wrapper, editBtn, input, saveBtn, cancelBtn, apiEndpoint); + } + + /** + * Create tag wrapper with display and input elements + */ + createTagWrapper(originalElement, tags) { + const wrapper = document.createElement('div'); + wrapper.className = 'tag-editor-wrapper'; + + // Display mode + const display = document.createElement('div'); + display.className = 'tag-display flex items-center gap-2 flex-wrap'; + + const tagsHtml = + TagEditor.renderTagsHtml(tags) || 'No tags'; + + display.innerHTML = ` +
${tagsHtml}
+ `; + + // Edit button + const editBtn = document.createElement('button'); + editBtn.className = 'tag-edit-btn px-2 py-1 text-sm text-accent hover:text-accent-hover transition-colors'; + editBtn.innerHTML = `${ICON_EDIT} Edit`; + display.appendChild(editBtn); + + // Edit mode (hidden input for tagger) + const edit = document.createElement('div'); + edit.className = 'tag-edit hidden'; + // Set explicit width for edit area + edit.style.width = '50em'; + edit.style.maxWidth = '100%'; // Don't overflow container + + const inputWrapper = document.createElement('div'); + inputWrapper.className = 'flex gap-2 items-center mb-2'; + + const input = document.createElement('input'); + input.type = 'text'; + input.name = 'tags'; + input.className = 'tag-input flex-1'; + input.placeholder = 'Add tags...'; + // Use smaller font size with rem units + input.style.fontSize = '0.75rem'; // 12px equivalent + + // Save button + const saveBtn = document.createElement('button'); + saveBtn.className = 'tag-save-btn px-3 py-1.5 text-sm bg-accent text-white rounded-button hover:bg-accent-hover transition-colors'; + saveBtn.textContent = 'Save'; + + // Cancel button + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'tag-cancel-btn px-3 py-1.5 text-sm bg-secondary text-white rounded-button hover:bg-secondary-hover transition-colors'; + cancelBtn.textContent = 'Cancel'; + + inputWrapper.appendChild(input); + inputWrapper.appendChild(cancelBtn); + inputWrapper.appendChild(saveBtn); + edit.appendChild(inputWrapper); + + // Error display + const errorDiv = document.createElement('div'); + errorDiv.className = 'tag-error hidden mt-2 p-2 bg-red-50 text-status-error rounded text-sm border border-status-error'; + edit.appendChild(errorDiv); + + wrapper.appendChild(display); + wrapper.appendChild(edit); + + return { wrapper, editBtn, input, saveBtn, cancelBtn }; + } + + /** + * Attach event listeners + */ + attachEventListeners(wrapper, editBtn, input, saveBtn, cancelBtn, apiEndpoint) { + const signal = this._abortController.signal; + // Store the tags when entering edit mode for cancel restore + let tagsBeforeEdit = []; + + // Prevent clicks from propagating to parent card + wrapper.addEventListener( + 'click', + (e) => { + e.stopPropagation(); + }, + { signal } + ); + + // Edit button toggles edit mode + editBtn.addEventListener( + 'click', + (e) => { + e.stopPropagation(); + // Save current tags before entering edit mode + tagsBeforeEdit = TagEditor.parseTags(input.value); + this.toggleEditMode(wrapper, input); + }, + { signal } + ); + + // Save button saves and closes edit mode + saveBtn.addEventListener( + 'click', + async (e) => { + e.stopPropagation(); + try { + await this.saveTags(wrapper, input, apiEndpoint); + } catch (error) { + // Error is already logged in saveTags() + } + this.closeEditMode(wrapper, input); + }, + { signal } + ); + + // Cancel button discards changes and closes edit mode + cancelBtn.addEventListener( + 'click', + (e) => { + e.stopPropagation(); + // Restore original tags and update display + this.restoreTags(wrapper, input, tagsBeforeEdit); + this.closeEditMode(wrapper, input); + }, + { signal } + ); + + // Close on Escape - cancel without saving + wrapper.addEventListener( + 'keydown', + (e) => { + if (e.key === 'Escape' && this.isEditing) { + e.preventDefault(); + e.stopPropagation(); + // Restore original tags and close (same as Cancel) + this.restoreTags(wrapper, input, tagsBeforeEdit); + this.closeEditMode(wrapper, input); + } + }, + { capture: true, signal } + ); // true = capture phase + } + + /** + * Toggle edit mode + */ + toggleEditMode(wrapper, input) { + const display = wrapper.querySelector('.tag-display'); + const edit = wrapper.querySelector('.tag-edit'); + + if (this.isEditing) { + this.closeEditMode(wrapper, input); + } else { + if (guardReadOnly()) return; + if (display) display.classList.add('hidden'); + if (edit) edit.classList.remove('hidden'); + this.isEditing = true; + input.focus(); + } + } + + /** + * Close edit mode + */ + closeEditMode(wrapper, input) { + const display = wrapper.querySelector('.tag-display'); + const edit = wrapper.querySelector('.tag-edit'); + const errorDiv = wrapper.querySelector('.tag-error'); + + if (errorDiv) errorDiv.classList.add('hidden'); + if (edit) edit.classList.add('hidden'); + if (display) display.classList.remove('hidden'); + this.isEditing = false; + } + + /** + * Restore tags to a previous state (used by Cancel) + */ + restoreTags(wrapper, input, tags) { + // Update the input value + input.value = tags.join(','); + + // Rebuild tagger UI by triggering a refresh + // The tagger library reads from input.value, so we need to reinitialize the tags display + const taggerContainer = input.parentElement; + if (taggerContainer?.classList.contains('tagger')) { + // Remove existing tag elements + const existingTags = taggerContainer.querySelectorAll('.tagger-tag'); + for (const tag of existingTags) { + tag.remove(); + } + + // Re-add tags from the restored value + for (const tagText of tags) { + const tagSpan = document.createElement('span'); + tagSpan.className = 'tagger-tag'; + tagSpan.innerHTML = `${escapeHtml(tagText)}Ɨ`; + // Insert before the input + taggerContainer.insertBefore(tagSpan, input); + } + } + + // Update display + this.updateDisplay(wrapper, tags); + } + + /** + * Save tags to server + */ + async saveTags(wrapper, input, apiEndpoint) { + const errorDiv = wrapper.querySelector('.tag-error'); + errorDiv.classList.add('hidden'); + + // Get current tags from input + const tags = TagEditor.parseTags(input.value); + + try { + const response = await fetch(apiEndpoint, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ tags }), + }); + + if (!response.ok) { + let detail = `Server error: ${response.status}`; + let rawBody = null; + try { + const errorData = await response.json(); + detail = errorData.detail || detail; + } catch { + if (isDev()) { + try { + rawBody = await response.text(); + } catch { + // ignore + } + } + } + throw new Error(isDev() && rawBody ? `${detail} (raw: ${rawBody.slice(0, 200)})` : detail); + } + + let updatedMetadata; + try { + updatedMetadata = await response.json(); + } catch { + throw new Error('Failed to parse server response'); + } + + // Update display with saved tags + this.updateDisplay(wrapper, updatedMetadata.tags || []); + } catch (error) { + console.error('Error saving tags:', error); + this.showError(wrapper, error.message || 'Failed to save tags'); + } + } + + /** + * Update the tag display with new content + */ + updateDisplay(wrapper, tags) { + const tagList = wrapper.querySelector('.tag-display .tag-list'); + + if (tags.length > 0) { + tagList.innerHTML = TagEditor.renderTagsHtml(tags); + } else { + tagList.innerHTML = 'No tags'; + } + } + + /** + * Show error message + */ + showError(wrapper, message) { + const errorDiv = wrapper.querySelector('.tag-error'); + errorDiv.textContent = message; + errorDiv.classList.remove('hidden'); + } + + /** + * Remove all event listeners and clean up. + * Call this when the editor is no longer needed to prevent memory leaks. + */ + destroy() { + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + } + this.taggerInstance = null; + this.isEditing = false; + } +} + +/** + * Initialize tag editors for elements matching a selector + * @param {string} selector - CSS selector for tag containers + * @param {function} getApiEndpoint - Function that takes container and returns API endpoint (or null to skip) + * @returns {TagEditor[]} Array of initialized TagEditor instances (for cleanup via destroy()) + */ +function initializeTagEditorsForElements(selector, getApiEndpoint) { + const tagContainers = document.querySelectorAll(selector); + const editors = []; + for (const container of tagContainers) { + const apiEndpoint = getApiEndpoint(container); + if (!apiEndpoint) continue; + + const tagEditor = new TagEditor(); + + // Get current tags (use :scope > span to get direct children only) + const tagElements = container.querySelectorAll(':scope > span'); + const currentTags = Array.from(tagElements) + .map((el) => el.textContent.trim()) + .filter((tag) => tag.length > 0); + + // Initialize tag editor + tagEditor.init(container, apiEndpoint, currentTags); + editors.push(tagEditor); + } + return editors; +} + +// Export for use in other scripts +export { TagEditor, initializeTagEditorsForElements }; +window.TagEditor = TagEditor; diff --git a/src/aspara/dashboard/static/js/timer-utils.js b/src/aspara/dashboard/static/js/timer-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..d48872936171f2da6ab1dd0f8adbe6526be999b4 --- /dev/null +++ b/src/aspara/dashboard/static/js/timer-utils.js @@ -0,0 +1,43 @@ +/** + * @file Timer-related utilities shared across the dashboard. + * + * Single source of truth for debounce timing used by search/filter inputs. + */ + +/** + * Default debounce delay (ms) for search/filter inputs. + * Balances responsiveness against re-render cost on large lists. + */ +export const SEARCH_DEBOUNCE_MS = 300; + +/** + * Create a debounced wrapper around `fn` that delays invocation until `delay` + * ms have elapsed since the last call. The wrapper exposes a `.cancel()` + * method to clear any pending invocation (call it during teardown). + * + * @param {function(...*): void} fn - Function to debounce. + * @param {number} [delay=SEARCH_DEBOUNCE_MS] - Debounce window in milliseconds. + * @returns {{ (...*): void, cancel(): void }} Debounced function with `.cancel()`. + */ +export function debounce(fn, delay = SEARCH_DEBOUNCE_MS) { + let timeoutId = null; + + function debounced(...args) { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => { + timeoutId = null; + fn(...args); + }, delay); + } + + debounced.cancel = () => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + + return debounced; +} diff --git a/src/aspara/dashboard/templates/_chart_size_controls.mustache b/src/aspara/dashboard/templates/_chart_size_controls.mustache new file mode 100644 index 0000000000000000000000000000000000000000..61937c5795d2c97906f4640b167ef46c4f50310b --- /dev/null +++ b/src/aspara/dashboard/templates/_chart_size_controls.mustache @@ -0,0 +1,6 @@ +Chart size: +
+ + + +
diff --git a/src/aspara/dashboard/templates/_delete_dialog.mustache b/src/aspara/dashboard/templates/_delete_dialog.mustache new file mode 100644 index 0000000000000000000000000000000000000000..bd2238cc61d99f13815a1404c67f7d3d6754cfa1 --- /dev/null +++ b/src/aspara/dashboard/templates/_delete_dialog.mustache @@ -0,0 +1,26 @@ + +
+
+
+ +
+
+

Delete

+

+
+
+
+ + +
+
+
diff --git a/src/aspara/dashboard/templates/_icons.mustache b/src/aspara/dashboard/templates/_icons.mustache new file mode 100644 index 0000000000000000000000000000000000000000..7515e75f9467efe9d9c27b19e6fc288a5dee897e --- /dev/null +++ b/src/aspara/dashboard/templates/_icons.mustache @@ -0,0 +1,47 @@ +{{! + Auto-generated icon sprites from heroicons. + Do not edit manually - run "pnpm build:icons" to regenerate. + + Source: icons.config.json +}} + diff --git a/src/aspara/dashboard/templates/_read_only_dialog.mustache b/src/aspara/dashboard/templates/_read_only_dialog.mustache new file mode 100644 index 0000000000000000000000000000000000000000..d43f9ba089868b58b067bd727fd791cc182fa83b --- /dev/null +++ b/src/aspara/dashboard/templates/_read_only_dialog.mustache @@ -0,0 +1,22 @@ + +
+
+
+ +
+
+

Read-only Mode

+

This is a demo instance running in read-only mode. Editing is disabled.

+
+
+
+ +
+
+
diff --git a/src/aspara/dashboard/templates/layout.mustache b/src/aspara/dashboard/templates/layout.mustache new file mode 100644 index 0000000000000000000000000000000000000000..34d8cdd1b235499fba57c420a0d83605033d36ac --- /dev/null +++ b/src/aspara/dashboard/templates/layout.mustache @@ -0,0 +1,111 @@ + + + + + + {{page_title}} - Aspara + + + + + + + + + + + {{> _icons}} + +
+ + + + +
+ + +
+
+ {{{content}}} +
+
+ + +
+
+ Aspara Metrics Tracker +
+
+
+ {{> _delete_dialog}} + {{> _read_only_dialog}} + {{#read_only}} + + {{/read_only}} + + + + + + + diff --git a/src/aspara/dashboard/templates/project_detail.mustache b/src/aspara/dashboard/templates/project_detail.mustache new file mode 100644 index 0000000000000000000000000000000000000000..d0d0179f8a1f974d951f8d7c6c0319f6b42c21eb --- /dev/null +++ b/src/aspara/dashboard/templates/project_detail.mustache @@ -0,0 +1,175 @@ +
+ +
+
+
+

{{project}}

+

+ {{run_count}} runs
+ Last updated {{formatted_project_last_update}} +

+
+ {{#has_runs}} + + View All Runs → + + {{/has_runs}} +
+
+ + +
+
+

Project Note

+
+
+
+
+ + {{#has_runs}} +
+ +
+
+ +
+ + +
+ + + + + + +
+
+ + +
+ + + +
+ +
+ + + + +

Loading metrics...

+
+ + + + + + + + + +
+
+
+ {{/has_runs}} + + {{^has_runs}} +
+

No runs found in this project.

+

Add a run by logging from your training script:

+
import aspara
+
+aspara.init(project="{{project}}")
+aspara.log({"loss": 0.5})
+aspara.finish()
+
+ {{/has_runs}} +
+ + + diff --git a/src/aspara/dashboard/templates/projects_list.mustache b/src/aspara/dashboard/templates/projects_list.mustache new file mode 100644 index 0000000000000000000000000000000000000000..4731e9b8dd580f2ecaf619eeb3cc059adeb24977 --- /dev/null +++ b/src/aspara/dashboard/templates/projects_list.mustache @@ -0,0 +1,84 @@ +
+ +
+

Projects

+

All your tracked projects

+
+ + {{#has_projects}} +
+
+ +
+ +
+ + +
+ Sort by: + + + +
+ +
+ {{#projects}} +
+
+
+
+

{{name}}

+
+ {{#tags}} + {{.}} + {{/tags}} +
+
+

{{run_count}} runs

+
+ Last updated: {{formatted_last_update}} +
+
+
+ +
+
+
+ {{/projects}} +
+ {{/has_projects}} + + {{^has_projects}} +
+

No projects found.

+

Create one by logging a run in your training script:

+
import aspara
+
+aspara.init(project="my_project")
+aspara.log({"loss": 0.5})
+aspara.finish()
+
+ {{/has_projects}} +
+ + diff --git a/src/aspara/dashboard/templates/run_detail.mustache b/src/aspara/dashboard/templates/run_detail.mustache new file mode 100644 index 0000000000000000000000000000000000000000..68e578a895c20ad39b4016db65b734b9406dc008 --- /dev/null +++ b/src/aspara/dashboard/templates/run_detail.mustache @@ -0,0 +1,138 @@ +
+ +
+ +
+
+

+ {{run_name}} + {{#is_corrupted}} + + Corrupted Run + + {{/is_corrupted}} +

+

Project: {{project}}

+ {{#is_corrupted}} +

{{error_message}}

+

This run's metadata may be incomplete. You can delete it from the runs list and re-run your experiment.

+ {{/is_corrupted}} +
+ +
+
+ Start Time: + {{formatted_start_time}} +
+
+ Duration: + {{duration}} +
+
+ Steps: + {{step_count}} +
+
+ Status: + + {{#is_corrupted}} + Error + {{/is_corrupted}} + {{^is_corrupted}} + {{#is_wip}}Running{{/is_wip}} + {{#is_completed}}Completed{{/is_completed}} + {{#is_failed}}Failed{{/is_failed}} + {{#is_maybe_failed}}Maybe Failed{{/is_maybe_failed}} + {{/is_corrupted}} + +
+
+ +
+
Tags
+
+ {{#tags}} + {{.}} + {{/tags}} +
+
+ +
+
+
Note
+
+
+
+
+
+ + +
+
+

Metric Charts

+ +
+
+ +
+
+
+ + +
+ +
+

Parameters

+ {{#has_params}} +
+ {{#params}} +
+
{{key}}
+
{{value}}
+
+ {{/params}} +
+ {{/has_params}} + {{^has_params}} +

No parameters logged for this run.

+ {{/has_params}} +
+ + +
+

Code & Artifacts

+ {{#has_artifacts}} +
+ {{#artifacts}} +
+
+ {{name}} + {{#description}} + {{description}} + {{/description}} +
+
+ {{file_size}}b +
+
+ {{/artifacts}} +
+ + Download ZIP + + {{/has_artifacts}} + {{^has_artifacts}} +

No artifacts available.

+ {{/has_artifacts}} +
+
+
+ + diff --git a/src/aspara/dashboard/templates/runs_list.mustache b/src/aspara/dashboard/templates/runs_list.mustache new file mode 100644 index 0000000000000000000000000000000000000000..7c4877ceaf3f8af836a34bee10591ba604e864cf --- /dev/null +++ b/src/aspara/dashboard/templates/runs_list.mustache @@ -0,0 +1,124 @@ +
+ +
+
+

Runs

+

Project: {{project}}

+
+
+ + {{#has_runs}} + +
+ Sort by: + + + +
+ +
+ {{#runs}} +
+ +
+
+ + {{#is_corrupted}} + + + + {{/is_corrupted}} + + {{^is_corrupted}} + + + + {{/is_corrupted}} + + +
+ {{name}} +
+
+ +
+ +
+ {{formatted_last_update}} +
+ + + +
+
+ + +
+ {{#has_tags}} + {{#tags}} + {{.}} + {{/tags}} + {{/has_tags}} +
+
+ {{/runs}} +
+ {{/has_runs}} + + {{^has_runs}} +
+

No runs found.

+

Add a run to this project by logging from your training script:

+
import aspara
+
+aspara.init(project="{{project}}")
+aspara.log({"loss": 0.5})
+aspara.finish()
+
+ {{/has_runs}} +
+ +{{#has_runs}} + + + +{{/has_runs}} diff --git a/src/aspara/dashboard/utils/__init__.py b/src/aspara/dashboard/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a451d1fc0dfde5fd5fbe1b8a515318f04a5b104c --- /dev/null +++ b/src/aspara/dashboard/utils/__init__.py @@ -0,0 +1,13 @@ +"""Dashboard utility functions.""" + +from .compression import ( + compress_metrics, + delta_compress, +) +from .run_list import parse_and_validate_run_list + +__all__ = [ + "compress_metrics", + "delta_compress", + "parse_and_validate_run_list", +] diff --git a/src/aspara/dashboard/utils/compression.py b/src/aspara/dashboard/utils/compression.py new file mode 100644 index 0000000000000000000000000000000000000000..bb323b6c9b2c848a0002f4521e98e067f98ea9f9 --- /dev/null +++ b/src/aspara/dashboard/utils/compression.py @@ -0,0 +1,141 @@ +""" +Metrics transformation utilities for dashboard. + +This module provides functions for downsampling and compressing metrics data +for efficient transfer to the frontend. +""" + +from __future__ import annotations + +import logging +import time + +import numpy as np +import polars as pl + +from aspara import lttb +from aspara.config import get_resource_limits + +logger = logging.getLogger(__name__) + + +def delta_compress(values: list[int | float]) -> list[int | float]: + """Delta-compress a sequence using NumPy. + + Args: + values: List of numeric values + + Returns: + Delta-compressed list where first value is absolute, + subsequent values are deltas from previous value + """ + if not values: + return [] + arr = np.asarray(values) + deltas = np.concatenate([[arr[0]], np.diff(arr)]) + return deltas.tolist() + + +def compress_metrics(df: pl.DataFrame) -> dict[str, dict[str, list]]: + """Compress metrics DataFrame for API response. + + Applies LTTB downsampling and delta compression to reduce data size. + Optimized for performance by applying LTTB during DataFrame processing. + + Args: + df: Wide-format DataFrame with columns: timestamp, step, _ + + Returns: + Metric-first format with delta-compressed arrays: + {metric_name: {steps: [...], values: [...], timestamps: [...]}} + - steps: delta-compressed (monotonically increasing) + - timestamps: unix time in milliseconds, delta-compressed + """ + if len(df) == 0: + return {} + + limits = get_resource_limits() + threshold = limits.lttb_threshold + + # Get all metric columns (those starting with underscore) + metric_cols = [col for col in df.columns if col.startswith("_")] + + if not metric_cols: + return {} + + logger.debug(f"[LTTB] Starting downsampling: {len(df)} rows, {len(metric_cols)} metrics") + start_total = time.time() + + # 1.2 Optimization: Pre-convert timestamp to milliseconds once before the loop + t1 = time.time() + df = df.with_columns(pl.col("timestamp").dt.epoch(time_unit="ms").alias("timestamp_ms")) + t2 = time.time() + logger.debug(f"[LTTB] Pre-convert timestamp_ms: {(t2 - t1) * 1000:.1f}ms") + + result: dict[str, dict[str, list]] = {} + + for i, metric_col in enumerate(metric_cols): + start_metric = time.time() + metric_name = metric_col[1:] # Remove underscore prefix + + # Extract step, value, and timestamp_ms for this metric, dropping nulls + t1 = time.time() + metric_data = df.select(["step", metric_col, "timestamp_ms"]).drop_nulls() + t2 = time.time() + logger.debug(f" [Metric {i + 1}/{len(metric_cols)}] select+drop_nulls: {(t2 - t1) * 1000:.1f}ms ({len(metric_data)} rows)") + + if len(metric_data) == 0: + continue + + if len(metric_data) <= threshold: + # No downsampling needed - extract all columns at once + t1 = time.time() + # 1.3 Optimization: Single extraction for all columns + steps = metric_data["step"].to_list() + values = metric_data[metric_col].to_list() + timestamps_ms = metric_data["timestamp_ms"].to_list() + t2 = time.time() + logger.debug(f" [Metric {i + 1}/{len(metric_cols)}] to_list (no downsample): {(t2 - t1) * 1000:.1f}ms") + else: + # Prepare data for LTTB: [[step, value], ...] + t1 = time.time() + lttb_input = metric_data.select(["step", metric_col]).to_numpy() + t2 = time.time() + logger.debug(f" [Metric {i + 1}/{len(metric_cols)}] to_numpy: {(t2 - t1) * 1000:.1f}ms") + + # Apply LTTB downsampling with indices + t1 = time.time() + lttb_output, indices = lttb.downsample(lttb_input, n_out=threshold, return_indices=True) + t2 = time.time() + logger.debug(f" [Metric {i + 1}/{len(metric_cols)}] lttb.downsample: {(t2 - t1) * 1000:.1f}ms ({len(lttb_output)} out)") + + # 1.3 Optimization: Single index operation then extract all columns + t1 = time.time() + selected = metric_data[indices] + steps = selected["step"].to_list() + values = selected[metric_col].to_list() + timestamps_ms = selected["timestamp_ms"].to_list() + t2 = time.time() + logger.debug(f" [Metric {i + 1}/{len(metric_cols)}] extract by indices: {(t2 - t1) * 1000:.1f}ms") + + # Delta-compress steps and timestamps (monotonically increasing) + # 1.4 Optimization: Uses NumPy vectorized delta_compress + t1 = time.time() + steps_delta = delta_compress(steps) + timestamps_delta = delta_compress(timestamps_ms) + t2 = time.time() + logger.debug(f" [Metric {i + 1}/{len(metric_cols)}] delta compression: {(t2 - t1) * 1000:.1f}ms") + + result[metric_name] = { + "steps": steps_delta, + "values": values, + "timestamps": timestamps_delta, + } + + elapsed_metric = time.time() - start_metric + logger.debug(f" [Metric {i + 1}/{len(metric_cols)}] TOTAL: {elapsed_metric * 1000:.1f}ms") + + elapsed_total = time.time() - start_total + logger.debug(f"[LTTB] TOTAL: {elapsed_total * 1000:.1f}ms -> {len(result)} metrics") + + return result diff --git a/src/aspara/dashboard/utils/run_list.py b/src/aspara/dashboard/utils/run_list.py new file mode 100644 index 0000000000000000000000000000000000000000..1fdedf54afa18734ccfc75d9903019f2986471cf --- /dev/null +++ b/src/aspara/dashboard/utils/run_list.py @@ -0,0 +1,36 @@ +"""Helpers for parsing and validating comma-separated run lists.""" + +from __future__ import annotations + +from aspara.config import get_resource_limits +from aspara.utils import validators + + +def parse_and_validate_run_list(runs: str) -> list[str]: + """Parse comma-separated run names and validate them. + + Args: + runs: Comma-separated run names (e.g. ``"run1,run2,run3"``). + + Returns: + List of validated, stripped run names. + + Raises: + ValueError: If no runs are specified, too many runs are requested, + or any run name fails validation. + """ + if not runs: + raise ValueError("No runs specified") + + run_list = [r.strip() for r in runs.split(",") if r.strip()] + if not run_list: + raise ValueError("No valid runs specified") + + limits = get_resource_limits() + if len(run_list) > limits.max_metric_names: + raise ValueError(f"Too many runs: {len(run_list)} (max: {limits.max_metric_names})") + + for run_name in run_list: + validators.validate_run_name(run_name) + + return run_list diff --git a/src/aspara/exceptions.py b/src/aspara/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..eabb9838406d8862dddb9c1e641928efa6aa579d --- /dev/null +++ b/src/aspara/exceptions.py @@ -0,0 +1,30 @@ +""" +Aspara exceptions module. + +Contains exception classes used across multiple modules to avoid circular dependencies. +""" + + +class ProjectNotFoundError(Exception): + """Exception raised when a project is not found.""" + + pass + + +class RunNotFoundError(Exception): + """Exception raised when a run is not found.""" + + pass + + +class RunAlreadyExistsError(Exception): + """Exception raised when creating a run that already exists without resume. + + Raised by LocalRun (and the tracker API maps it to HTTP 409) when a run + with the same name already exists in the project and the caller did not + set resume=True. Appending to the existing file would mix metrics from + independent run instances and corrupt step numbering, so this is treated + as an error rather than a warning. + """ + + pass diff --git a/src/aspara/logger.py b/src/aspara/logger.py new file mode 100644 index 0000000000000000000000000000000000000000..9df201b234920016327bb2b7761b87ba02d44301 --- /dev/null +++ b/src/aspara/logger.py @@ -0,0 +1,32 @@ +"""Logging configuration for Aspara.""" + +import logging +import sys + +# Create logger for Aspara +logger = logging.getLogger("aspara") + + +def setup_logger(level: int = logging.INFO) -> None: + """Setup the Aspara logger with default configuration. + + Args: + level: Logging level (default: INFO) + """ + if logger.handlers: + # Already configured + return + + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(level) + + formatter = logging.Formatter("aspara: %(message)s") + handler.setFormatter(formatter) + + logger.addHandler(handler) + logger.setLevel(level) + logger.propagate = False + + +# Initialize logger on import +setup_logger() diff --git a/src/aspara/lttb/LICENSE b/src/aspara/lttb/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..db2c16715987b591371a0d109b999533a9f26d46 --- /dev/null +++ b/src/aspara/lttb/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 JA Viljoen + +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/src/aspara/lttb/__init__.py b/src/aspara/lttb/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4777cebf1f164fe9bc4887e88f17e2e8c8724c08 --- /dev/null +++ b/src/aspara/lttb/__init__.py @@ -0,0 +1,62 @@ +"""LTTB (Largest Triangle Three Buckets) downsampling implementation. + +This module is based on lttb-numpy by JA Viljoen, licensed under the MIT License. +Original source: https://git.sr.ht/~javiljoen/lttb-numpy +Copyright (c) 2020 JA Viljoen + +This is a local optimized copy for Aspara. See LICENSE file for full license text. +See lttb.py for details on optimizations applied. +""" + +from .lttb import downsample as _downsample_original +from .lttb import downsample_fast as _downsample_fast +from .lttb import downsample_fast_v2 as _downsample_fast_v2 +from .lttb import downsample_fast_v3 as _downsample_fast_v3 + + +def downsample(data, n_out, validators=None, return_indices=False): + """Downsample data using LTTB algorithm. + + The implementation can be switched between the original LTTB and a faster + centroid-based variant using the ASPARA_LTTB_FAST environment variable. + + - ASPARA_LTTB_FAST=0 or unset: Use original LTTB (default) + - ASPARA_LTTB_FAST=1: Use faster centroid-based variant + + Parameters + ---------- + data : numpy.array + A 2-dimensional array with time values in the first column + n_out : int + Number of data points to downsample to + validators : sequence of callables, optional + Validation functions to apply + return_indices : bool, optional + If True, also return the indices of selected points + + Returns + ------- + numpy.array or tuple + If return_indices is False: Downsampled array of shape (n_out, 2) + If return_indices is True: Tuple of (downsampled array, indices array) + """ + from aspara.config import use_lttb_fast + + use_fast = use_lttb_fast() + + if use_fast: + if validators is None: + return _downsample_fast(data, n_out) + return _downsample_fast(data, n_out, validators) + else: + if validators is None: + return _downsample_original(data, n_out, return_indices=return_indices) + return _downsample_original(data, n_out, validators, return_indices=return_indices) + + +__all__ = ["downsample", "downsample_fast", "downsample_fast_v2", "downsample_fast_v3"] + +# Re-export for direct access if needed +downsample_fast = _downsample_fast +downsample_fast_v2 = _downsample_fast_v2 +downsample_fast_v3 = _downsample_fast_v3 diff --git a/src/aspara/lttb/lttb.py b/src/aspara/lttb/lttb.py new file mode 100644 index 0000000000000000000000000000000000000000..7b1d4891e85847f34990e2508abc090214efd374 --- /dev/null +++ b/src/aspara/lttb/lttb.py @@ -0,0 +1,424 @@ +"""Downsample data using the Largest-Triangle-Three-Buckets algorithm. + +This module is based on lttb-numpy by JA Viljoen, licensed under the MIT License. +Original source: https://git.sr.ht/~javiljoen/lttb-numpy +Copyright (c) 2020 JA Viljoen + +Modifications for Aspara: +- Optimized bin centroid calculation using np.add.reduceat +- Removed conditional branch in main loop +- Optimized _areas_of_triangles function (removed redundant operations, 0.5 factor) + +Reference +--------- +Sveinn Steinarsson. 2013. Downsampling Time Series for Visual +Representation. MSc thesis. University of Iceland. +""" + +from __future__ import annotations + +import numpy as np + +from .validators import ( + contains_no_nans, + has_two_columns, + validate, + x_is_strictly_increasing, +) + +default_validators = [has_two_columns, contains_no_nans, x_is_strictly_increasing] + + +def _areas_of_triangles(a, bs, c): + """Calculate areas of triangles from duples of vertex coordinates. + + Uses implicit numpy broadcasting along first axis of ``bs``. + + Note: Returns 2x the actual area since we only need relative magnitudes + for comparison (argmax), not absolute areas. + + Returns + ------- + numpy.array + Array of area measures of shape (len(bs),) + """ + # Optimized: removed redundant subtraction, use np.abs, and removed 0.5 factor + # (not needed since we only compare relative magnitudes) + return np.abs((a[0] - c[0]) * (bs[:, 1] - a[1]) + (bs[:, 0] - a[0]) * (c[1] - a[1])) + + +def _areas_of_triangles_vectorized(a_points, b_points, c_points): + """Calculate areas of triangles with vectorized a and c points. + + This is a fully vectorized version where a, b, and c are all arrays + of the same length, representing multiple triangles. + + Parameters + ---------- + a_points : numpy.array + Array of shape (n, 2) representing the first vertices + b_points : numpy.array + Array of shape (n, 2) representing the second vertices + c_points : numpy.array + Array of shape (n, 2) representing the third vertices + + Returns + ------- + numpy.array + Array of area measures of shape (n,) + """ + return np.abs((a_points[:, 0] - c_points[:, 0]) * (b_points[:, 1] - a_points[:, 1]) + (b_points[:, 0] - a_points[:, 0]) * (c_points[:, 1] - a_points[:, 1])) + + +def downsample(data, n_out, validators=default_validators, return_indices=False): + """Downsample ``data`` to ``n_out`` points using the LTTB algorithm. + + Parameters + ---------- + data : numpy.array + A 2-dimensional array with time values in the first column + n_out : int + Number of data points to downsample to + validators : sequence of callables, optional + Validation functions that take an array as argument and + raise ``ValueError`` if the array fails some criterion + return_indices : bool, optional + If True, also return the indices of selected points in the original data array + + Constraints + ----------- + - ncols(data) == 2 + - 3 <= n_out <= nrows(data) + - the first column of ``data`` should be strictly monotonic. + + Returns + ------- + numpy.array or tuple + If return_indices is False: Array of shape (n_out, 2) + If return_indices is True: Tuple of (array of shape (n_out, 2), array of indices) + + Raises + ------ + ValueError + If ``data`` fails the validation checks, + or if ``n_out`` falls outside the valid range. + """ + # Validate input + validate(data, validators) + + if n_out > data.shape[0]: + raise ValueError("n_out must be <= number of rows in data") + + if n_out == data.shape[0]: + if return_indices: + return data, np.arange(len(data)) + return data + + if n_out < 3: + raise ValueError("Can only downsample to a minimum of 3 points") + + # Split data into bins + n_bins = n_out - 2 + middle_data = data[1:-1] + + # Pre-compute centroids of all bins using vectorized operations + # Calculate bin boundaries + bin_edges = np.linspace(0, len(middle_data), n_bins + 1, dtype=int) + + # Calculate sum for each bin using reduceat (vectorized operation) + bin_sums = np.add.reduceat(middle_data, bin_edges[:-1], axis=0) + + # Calculate bin sizes and compute means + bin_sizes = np.diff(bin_edges) + bin_centroids_data = bin_sums / bin_sizes[:, np.newaxis] + + # Append the last data point to eliminate conditional branch in loop + bin_centroids = np.vstack([bin_centroids_data, data[-1]]) + + # Prepare output array + # First and last points are the same as in the input. + out = np.zeros((n_out, 2)) + out[0] = data[0] + out[-1] = data[-1] + + # Track indices if requested + if return_indices: + indices = np.zeros(n_out, dtype=int) + indices[0] = 0 + indices[-1] = len(data) - 1 + + # Largest Triangle Three Buckets (LTTB): + # In each bin, find the point that makes the largest triangle + # with the point saved in the previous bin + # and the centroid of the points in the next bin. + for i in range(n_bins): + # Extract this bin's data using pre-computed bin edges + bin_start = bin_edges[i] + bin_end = bin_edges[i + 1] + this_bin = middle_data[bin_start:bin_end] + + a = out[i] + bs = this_bin + c = bin_centroids[i + 1] # No conditional needed! + + areas = _areas_of_triangles(a, bs, c) + local_idx = np.argmax(areas) + + out[i + 1] = bs[local_idx] + + if return_indices: + # Convert local bin index to global data index + # +1 because middle_data starts at index 1 + indices[i + 1] = bin_start + local_idx + 1 + + if return_indices: + return out, indices + return out + + +def downsample_fast(data, n_out, validators=default_validators): + """Downsample using a faster variant of LTTB with centroid-based previous points. + + This is a modified version of LTTB that uses bin centroids for the previous point (A) + instead of the actually selected point. This eliminates loop dependencies and enables + full vectorization, at the cost of slightly different downsampling behavior. + + Parameters + ---------- + data : numpy.array + A 2-dimensional array with time values in the first column + n_out : int + Number of data points to downsample to + validators : sequence of callables, optional + Validation functions that take an array as argument and + raise ``ValueError`` if the array fails some criterion + + Constraints + ----------- + - ncols(data) == 2 + - 3 <= n_out <= nrows(data) + - the first column of ``data`` should be strictly monotonic. + + Returns + ------- + numpy.array + Array of shape (n_out, 2) + + Raises + ------ + ValueError + If ``data`` fails the validation checks, + or if ``n_out`` falls outside the valid range. + """ + # Validate input + validate(data, validators) + + if n_out > data.shape[0]: + raise ValueError("n_out must be <= number of rows in data") + + if n_out == data.shape[0]: + return data + + if n_out < 3: + raise ValueError("Can only downsample to a minimum of 3 points") + + # Split data into bins + n_bins = n_out - 2 + middle_data = data[1:-1] + + # Pre-compute centroids of all bins using vectorized operations + bin_edges = np.linspace(0, len(middle_data), n_bins + 1, dtype=int) + bin_sums = np.add.reduceat(middle_data, bin_edges[:-1], axis=0) + bin_sizes = np.diff(bin_edges) + bin_centroids = bin_sums / bin_sizes[:, np.newaxis] + + # Prepare A points (previous bin's centroid or first point for first bin) + a_points = np.vstack([data[0:1], bin_centroids[:-1]]) # Shape: (n_bins, 2) + + # Prepare C points (next bin's centroid or last point for last bin) + c_points = np.vstack([bin_centroids[1:], data[-1:]]) # Shape: (n_bins, 2) + + # Repeat A and C points to match each candidate point in their respective bins + a_repeated = np.repeat(a_points, bin_sizes, axis=0) # Shape: (total_points, 2) + c_repeated = np.repeat(c_points, bin_sizes, axis=0) # Shape: (total_points, 2) + + # Calculate all triangle areas at once (fully vectorized) + all_areas = _areas_of_triangles_vectorized(a_repeated, middle_data, c_repeated) + + # Find the point with maximum area in each bin + # Split areas back into bins and find argmax for each bin + area_bins = np.split(all_areas, bin_edges[1:-1]) + selected_indices = np.array([np.argmax(bin_areas) for bin_areas in area_bins]) + + # Convert local bin indices to global middle_data indices + global_indices = bin_edges[:-1] + selected_indices + + # Prepare output array + out = np.zeros((n_out, 2)) + out[0] = data[0] + out[-1] = data[-1] + out[1:-1] = middle_data[global_indices] + + return out + + +def downsample_fast_v2(data, n_out, validators=default_validators): + """Two-stage LTTB downsampling for improved quality with vectorization. + + This variant performs LTTB in two stages: + 1. First stage: Use downsample_fast to get initial point selection + 2. Second stage: Use the selected points from stage 1 as fixed A points, + and recompute optimal B points with full vectorization + + This approach balances quality and speed - better than fast variant, + faster than original LTTB. + + Parameters + ---------- + data : numpy.array + A 2-dimensional array with time values in the first column + n_out : int + Number of data points to downsample to + validators : sequence of callables, optional + Validation functions that take an array as argument and + raise ``ValueError`` if the array fails some criterion + + Constraints + ----------- + - ncols(data) == 2 + - 3 <= n_out <= nrows(data) + - the first column of ``data`` should be strictly monotonic. + + Returns + ------- + numpy.array + Array of shape (n_out, 2) + + Raises + ------ + ValueError + If ``data`` fails the validation checks, + or if ``n_out`` falls outside the valid range. + """ + # Validate input + validate(data, validators) + + if n_out > data.shape[0]: + raise ValueError("n_out must be <= number of rows in data") + + if n_out == data.shape[0]: + return data + + if n_out < 3: + raise ValueError("Can only downsample to a minimum of 3 points") + + # Stage 1: Get initial selection using fast variant + initial_selection = downsample_fast(data, n_out, validators=[]) + + # Stage 2: Refine selection using initial points as fixed A points + n_bins = n_out - 2 + middle_data = data[1:-1] + + # Calculate bin boundaries + bin_edges = np.linspace(0, len(middle_data), n_bins + 1, dtype=int) + bin_sizes = np.diff(bin_edges) + + # Pre-compute centroids for C points (next bin) + bin_sums = np.add.reduceat(middle_data, bin_edges[:-1], axis=0) + bin_centroids = bin_sums / bin_sizes[:, np.newaxis] + + # Prepare A points from initial selection + # A[i] = initial_selection[i] for bin i (i=0 to n_bins-1) + a_points = initial_selection[:n_bins] # Shape: (n_bins, 2) + + # Prepare C points (next bin's centroid or last point for last bin) + c_points = np.vstack([bin_centroids[1:], data[-1:]]) # Shape: (n_bins, 2) + + # Repeat A and C points to match each candidate point in their respective bins + a_repeated = np.repeat(a_points, bin_sizes, axis=0) # Shape: (total_points, 2) + c_repeated = np.repeat(c_points, bin_sizes, axis=0) # Shape: (total_points, 2) + + # Calculate all triangle areas at once (fully vectorized) + all_areas = _areas_of_triangles_vectorized(a_repeated, middle_data, c_repeated) + + # Find the point with maximum area in each bin + area_bins = np.split(all_areas, bin_edges[1:-1]) + selected_indices = np.array([np.argmax(bin_areas) for bin_areas in area_bins]) + + # Convert local bin indices to global middle_data indices + global_indices = bin_edges[:-1] + selected_indices + + # Prepare output array + out = np.zeros((n_out, 2)) + out[0] = data[0] + out[-1] = data[-1] + out[1:-1] = middle_data[global_indices] + + return out + + +def downsample_fast_v3(data, n_out, validators=default_validators): + """Interleaved LTTB downsampling combining v2 and original algorithms. + + This variant combines two approaches by interleaving their results: + - Odd-indexed points: Use downsample_fast_v2 (fast, centroid-based refinement) + - Even-indexed points: Use downsample (original, high quality) + + This approach aims to balance quality and speed by mixing both algorithms. + + Parameters + ---------- + data : numpy.array + A 2-dimensional array with time values in the first column + n_out : int + Number of data points to downsample to + validators : sequence of callables, optional + Validation functions that take an array as argument and + raise ``ValueError`` if the array fails some criterion + + Constraints + ----------- + - ncols(data) == 2 + - 3 <= n_out <= nrows(data) + - the first column of ``data`` should be strictly monotonic. + + Returns + ------- + numpy.array + Array of shape (n_out, 2) + + Raises + ------ + ValueError + If ``data`` fails the validation checks, + or if ``n_out`` falls outside the valid range. + """ + # Validate input + validate(data, validators) + + if n_out > data.shape[0]: + raise ValueError("n_out must be <= number of rows in data") + + if n_out == data.shape[0]: + return data + + if n_out < 3: + raise ValueError("Can only downsample to a minimum of 3 points") + + # Get results from both algorithms + result_v2 = downsample_fast_v2(data, n_out, validators=[]) + result_original = downsample(data, n_out, validators=[]) + + # Prepare output array + out = np.zeros((n_out, 2)) + out[0] = data[0] # First point is always the same + out[-1] = data[-1] # Last point is always the same + + # Interleave: odd indices from v2, even indices from original + for i in range(1, n_out - 1): + if i % 2 == 1: # Odd index + out[i] = result_v2[i] + else: # Even index + out[i] = result_original[i] + + return out diff --git a/src/aspara/lttb/validators.py b/src/aspara/lttb/validators.py new file mode 100644 index 0000000000000000000000000000000000000000..a3e05b2a0d4433a5ba641a6189a23f333e2ed4a1 --- /dev/null +++ b/src/aspara/lttb/validators.py @@ -0,0 +1,83 @@ +"""Functions to check that input data is in a valid format. + +This module is based on lttb-numpy by JA Viljoen, licensed under the MIT License. +Original source: https://git.sr.ht/~javiljoen/lttb-numpy +Copyright (c) 2020 JA Viljoen + +No modifications have been made to this module. +""" + +import numpy as np + + +def has_two_columns(data): + """Raise ValueError if ``data`` is not a 2D array with 2 columns.""" + if len(data.shape) != 2: + raise ValueError("data is not a 2D array") + + if data.shape[1] != 2: + raise ValueError("data does not have 2 columns") + + +def x_is_sorted(data): + """Raise ValueError if the first column of ``data`` is not sorted.""" + if np.any(data[1:, 0] < data[:-1, 0]): + raise ValueError("data is not sorted on the first column") + + +def x_is_strictly_increasing(data): + """Raise ValueError if 1st column is not strictly increasing. + + I.e. if the first column of ``data`` either is not sorted + or it contains repeated (duplicate) values. + """ + if np.any(data[1:, 0] <= data[:-1, 0]): + raise ValueError("first column is not strictly increasing") + + +def x_is_regular(data): + """Raise ValueError if 1st column of ``data`` is irregularly spaced. + + I.e. if the intervals between successive values in the first column + are not constant. + """ + if len(np.unique(np.diff(data[:, 0]))) != 1: + raise ValueError("first column is not regularly spaced") + + +def contains_no_nans(data): + """Raise ValueError if ``data`` contains any missing/NaN values.""" + if np.any(np.isnan(data)): + raise ValueError("data contains NaN values") + + +def validate(data, validators): + """Check an array against each of the given validators. + + All validators are run (rather than failing at the first error) + and their error messages are concatenated into the message for the + raised ``ValueError``, if any. + + Parameters + ---------- + data : numpy.array + Data to validate + validators : sequence of callables + Validation functions that take an array as argument and + raise ``ValueError`` if the array fails some criterion + + Raises + ------ + ValueError + If any of the validators raise a ``ValueError`` for ``data`` + """ + errors = [] + + for validator in validators: + try: + validator(data) + except ValueError as err: + errors.append(err) + + if errors: + raise ValueError("; ".join(map(str, errors))) diff --git a/src/aspara/models/__init__.py b/src/aspara/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4ee8b2123088a74facf02af9c9c3a097ae2b3808 --- /dev/null +++ b/src/aspara/models/__init__.py @@ -0,0 +1,10 @@ +""" +Aspara data models package. + +This package contains core data models used across all Aspara components. +""" + +from aspara.models.record import MetricRecord, StatusRecord +from aspara.models.status import RunStatus + +__all__ = ["MetricRecord", "StatusRecord", "RunStatus"] diff --git a/src/aspara/models/record.py b/src/aspara/models/record.py new file mode 100644 index 0000000000000000000000000000000000000000..7015f1abbcd514acb43130867a33e77f8540065f --- /dev/null +++ b/src/aspara/models/record.py @@ -0,0 +1,46 @@ +""" +Aspara core data models for metric records. + +This module contains the central data model used across all components +for representing individual metric records. +""" + +import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class MetricRecord(BaseModel): + """Unified metric record for all Aspara components + + This class represents a single metric record with timestamp, step, and metrics. + Used by both tracker API and catalog components. + """ + + timestamp: datetime.datetime = Field(default_factory=datetime.datetime.now, description="Recording timestamp") + step: int | None = Field(default=None, description="Step number (optional)") + metrics: dict[str, Any] = Field(..., description="Dictionary of metric names and their values") + run: str | None = Field(default=None, description="Run name (optional, useful for multi-run watch)") + project: str | None = Field(default=None, description="Project name (optional, useful for multi-run watch)") + + def __str__(self) -> str: + """String representation of the metric record.""" + return f"MetricRecord(timestamp={self.timestamp}, step={self.step}, metrics={list(self.metrics.keys())})" + + +class StatusRecord(BaseModel): + """Run status update record. + + Represents a change in run status (e.g., from WIP to COMPLETED). + """ + + timestamp: datetime.datetime = Field(default_factory=datetime.datetime.now, description="Update timestamp") + run: str = Field(..., description="Run name") + project: str = Field(..., description="Project name") + status: str = Field(..., description="New status value") + is_finished: bool = Field(..., description="Whether run is finished") + exit_code: int | None = Field(default=None, description="Exit code if finished") + + def __str__(self) -> str: + return f"StatusRecord(run={self.run}, status={self.status})" diff --git a/src/aspara/models/status.py b/src/aspara/models/status.py new file mode 100644 index 0000000000000000000000000000000000000000..06cf2acca569cd5892c93c27514bb527168b807e --- /dev/null +++ b/src/aspara/models/status.py @@ -0,0 +1,59 @@ +""" +Run status enumeration and utilities. + +This module provides the status enumeration for runs and utilities +for status management and detection. +""" + +from enum import Enum + + +class RunStatus(Enum): + """Run status enumeration. + + Represents the current state of a run: + - wip: Work in progress (actively running) + - failed: Run finished with non-zero exit code + - maybe_failed: Run likely failed (connection lost, process killed) + - completed: Run finished successfully (exit code 0) + """ + + WIP = "wip" + FAILED = "failed" + MAYBE_FAILED = "maybe_failed" + COMPLETED = "completed" + + @classmethod + def from_is_finished_and_exit_code(cls, is_finished: bool, exit_code: int | None) -> "RunStatus": + """Create status from is_finished flag and exit code. + + Args: + is_finished: Whether the run has finished + exit_code: Exit code (0 = success, non-zero = error) + + Returns: + RunStatus: Appropriate status + """ + if not is_finished: + return cls.WIP + elif exit_code is None: + return cls.MAYBE_FAILED + elif exit_code == 0: + return cls.COMPLETED + else: + return cls.FAILED + + def to_is_finished_and_exit_code(self) -> tuple[bool, int | None]: + """Convert status back to is_finished and exit_code. + + Returns: + Tuple of (is_finished, exit_code) + """ + if self == self.WIP: + return (False, None) + elif self == self.COMPLETED: + return (True, 0) + elif self == self.FAILED: + return (True, 1) # Non-zero exit code + else: # MAYBE_FAILED + return (True, None) diff --git a/src/aspara/run/__init__.py b/src/aspara/run/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..96a48aef39e81b25df91cbb0f5818d9e7d6bac7c --- /dev/null +++ b/src/aspara/run/__init__.py @@ -0,0 +1,30 @@ +"""Run package - experiment tracking core. + +This package provides the core run tracking functionality for aspara. +Users should use the Run class or module-level API (init, log, finish) +for creating and managing runs. + +Example: + >>> from aspara.run import Run, init, log, finish + >>> run = init(project="my_project") + >>> log({"loss": 0.5}) + >>> finish() +""" + +from aspara.run._api import finish, get_current_run, init, log +from aspara.run._config import Config +from aspara.run._summary import Summary +from aspara.run.run import Run + +# LocalRun/RemoteRun are internal implementation details +# Users should use the Run class + +__all__ = [ + "Run", + "Config", + "Summary", + "init", + "log", + "finish", + "get_current_run", +] diff --git a/src/aspara/run/_api.py b/src/aspara/run/_api.py new file mode 100644 index 0000000000000000000000000000000000000000..d600764f30d14ef8112c25b03411b43355fca4df --- /dev/null +++ b/src/aspara/run/_api.py @@ -0,0 +1,167 @@ +"""Module-level API for run management.""" + +from __future__ import annotations + +import threading +from typing import TYPE_CHECKING, Any + +from aspara.storage.metrics import resolve_metrics_storage_backend + +if TYPE_CHECKING: + from aspara.run.run import Run + + +_current_run: Run | None = None +_storage_backend: str = "jsonl" # Global storage backend setting +_lock = threading.Lock() + + +def init( + project: str | None = None, + name: str | None = None, + config: dict[str, Any] | None = None, + tags: list[str] | None = None, + notes: str | None = None, + dir: str | None = None, + tracker_uri: str | None = None, + storage_backend: str | None = None, + project_tags: list[str] | None = None, + resume: bool = False, +) -> Run: + """Initialize a new run. + + This is the main entry point for starting experiment tracking. + Similar to wandb.init(). + + Args: + project: Project name. Defaults to "default". + name: Run name. If None, generates a random name. + config: Initial configuration parameters. + tags: Tags attached to this individual run (run-wide). + notes: Run notes/description (wandb-compatible). + dir: Base directory for storing data. Defaults to XDG-based default (~/.local/share/aspara). + tracker_uri: Tracker server URI for remote mode. If None, uses local file storage. + storage_backend: Storage backend type ('jsonl' or 'polars'). Can also be set via ASPARA_STORAGE_BACKEND env var. + project_tags: Tags attached to the project as a whole (project-wide), shared across all runs in the project. Distinct from ``tags``, which are per-run. + resume: If True and a run with the same name already exists, resume it + (reuse run_id, reset finish state, continue step numbering). If no + existing run is found, a new run is created. + + Returns: + The initialized Run object. + + Examples: + Basic usage with local file storage: + + >>> import aspara + >>> run = aspara.init(project="my_project", config={"lr": 0.01}) + >>> aspara.log({"loss": 0.5}) + >>> aspara.finish() + + Using remote tracker: + + >>> run = aspara.init(project="my_project", tracker_uri="http://localhost:3142") + + Using Polars backend for efficient storage: + + >>> run = aspara.init(project="my_project", storage_backend="polars") + + Resuming an existing run after a crash: + + >>> run = aspara.init(project="my_project", name="run1", resume=True) + """ + global _current_run, _storage_backend + + with _lock: + # Finish previous run if exists + if _current_run is not None: + _current_run.finish(quiet=True) + + # Determine storage backend using central resolver + selected_backend = resolve_metrics_storage_backend(storage_backend) + _storage_backend = selected_backend + + # Create Run which internally delegates to LocalRun or RemoteRun based on tracker_uri + from aspara.run.run import Run + + run = Run( + name=name, + project=project, + config=config, + tags=tags, + notes=notes, + dir=dir, + storage_backend=selected_backend, + tracker_uri=tracker_uri, + project_tags=project_tags, + resume=resume, + ) + _current_run = run + + return run + + +def log( + data: dict[str, Any], + step: int | None = None, + commit: bool = True, + timestamp: str | None = None, +) -> None: + """Log metrics to the current run. + + Args: + data: Dictionary of metric names to values + step: Optional step number. If None, auto-increments. + commit: If True, commits the step. + timestamp: Optional timestamp in ISO format. If None, uses current time. + + Raises: + RuntimeError: If no run is active + + Examples: + >>> import aspara + >>> aspara.init(project="test") + >>> aspara.log({"loss": 0.5, "accuracy": 0.95}) + """ + if _current_run is None: + raise RuntimeError("No active run. Call aspara.init() first.") + + with _lock: + # Re-check inside the lock: finish() may have cleared _current_run + # between the check above and acquiring the lock. + if _current_run is None: + raise RuntimeError("No active run. Call aspara.init() first.") + _current_run.log(data, step=step, commit=commit, timestamp=timestamp) + + +def finish(exit_code: int = 0, quiet: bool = False) -> None: + """Finish the current run. + + Similar to wandb.finish(). + + Args: + exit_code: Exit code for the run (0 = success) + quiet: If True, suppress output messages + + Examples: + >>> import aspara + >>> aspara.init(project="test") + >>> aspara.log({"loss": 0.5}) + >>> aspara.finish() + """ + global _current_run + + with _lock: + if _current_run is not None: + _current_run.finish(exit_code=exit_code, quiet=quiet) + _current_run = None + + +def get_current_run() -> Run | None: + """Get the current active run. + + Returns: + The current Run object, or None if no run is active. + """ + with _lock: + return _current_run diff --git a/src/aspara/run/_base_run.py b/src/aspara/run/_base_run.py new file mode 100644 index 0000000000000000000000000000000000000000..2653dab251082e299738ac41cdecc97e4e346a5a --- /dev/null +++ b/src/aspara/run/_base_run.py @@ -0,0 +1,269 @@ +"""Base class providing shared run state and step management.""" + +from __future__ import annotations + +import uuid +from typing import Any, Literal + + +class BaseRun: + """Base class providing shared run state and step management. + + This class is intentionally minimal and focuses on common runtime + behaviour such as step tracking and finished-state management. + Concrete run implementations (e.g., LocalRun, RemoteRun) are + responsible for I/O specifics. + """ + + def __init__( + self, + name: str | None = None, + project: str | None = None, + tags: list[str] | None = None, + notes: str | None = None, + ) -> None: + # Common attributes (id is set by subclasses) + self.id: str + self.name = name or self._generate_run_name() + self.project = project or "default" + self.tags = tags or [] + self.notes = notes or "" + + # Step management + self._current_step: int = 0 + self._step_committed: bool = True + # Completion flag shared by all runs + self._finished: bool = False + + # ---- Run ID/Name generation helpers -------------------------------- + + @staticmethod + def _generate_run_id() -> str: + """Generate a unique run ID. + + Returns: + 16-character unique identifier + """ + return uuid.uuid4().hex[:16] + + @staticmethod + def _generate_run_name() -> str: + """Generate a random run name. + + Returns: + Human-readable random name (adjective-noun-number format) + """ + import random + + adjectives = [ + "happy", + "clever", + "swift", + "brave", + "calm", + "eager", + "gentle", + "kind", + "lively", + "proud", + "quiet", + "wise", + "bold", + "bright", + ] + nouns = [ + "falcon", + "tiger", + "eagle", + "wolf", + "bear", + "hawk", + "lion", + "dolphin", + "phoenix", + "dragon", + "panda", + "fox", + "owl", + "raven", + ] + + adj = random.choice(adjectives) + noun = random.choice(nouns) + num = random.randint(1, 999) + + return f"{adj}-{noun}-{num}" + + # ---- Step / finished state helpers --------------------------------- + + def _ensure_not_finished(self) -> None: + """Raise if the run has already been finished. + + Both LocalRun and RemoteRun currently use the same error message, + so we centralize it here. + """ + + if self._finished: + raise RuntimeError("Cannot log to a finished run") + + def _prepare_step(self, step: int | None, commit: bool) -> int: + """Prepare the step value before logging. + + This mirrors the existing behaviour: + - If an explicit step is provided, use it as-is. + - Otherwise, keep the current step value. Auto-increment happens + *after* logging when commit=True. + + The ``_step_committed`` flag is reset here so that it always + reflects whether the *current* step has been committed by a + subsequent ``_after_log`` call. Without this reset, an explicit + ``step=`` argument would leave the flag stale from the previous + log call. + """ + + if step is not None: + self._current_step = step + # Mark the current step as not-yet-committed. _after_log will set + # it to True (commit=True) or False (commit=False) after logging. + self._step_committed = False + return self._current_step + + def _after_log(self, commit: bool) -> None: + """Update internal step state after a log call.""" + + if commit: + self._current_step += 1 + self._step_committed = True + else: + self._step_committed = False + + def _mark_finished(self) -> bool: + """Mark the run as finished. + + Returns: + bool: False if the run was already finished, True otherwise. + """ + + if self._finished: + return False + self._finished = True + return True + + # ---- Public interface (to be implemented by subclasses) --------------- + + def finish(self, exit_code: int = 0, quiet: bool = False) -> None: + """Finish the run. Implemented by subclasses.""" + raise NotImplementedError + + # ---- Context manager protocol ---------------------------------------- + + def __enter__(self) -> BaseRun: + """Enter context manager.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> Literal[False]: + """Exit context manager, ensuring run is finished. + + Args: + exc_type: Exception type if an exception was raised, None otherwise. + exc_val: Exception value if an exception was raised, None otherwise. + exc_tb: Traceback if an exception was raised, None otherwise. + + Returns: + False to propagate any exceptions that occurred. + """ + # Set exit_code=1 if exception occurred + exit_code = 1 if exc_type is not None else 0 + self.finish(exit_code=exit_code, quiet=True) + return False # Do not suppress exceptions + + # ---- Metrics validation ---------------------------------------------- + + def _validate_metrics(self, data: dict[str, Any]) -> dict[str, float | int]: + """Validate and normalize metric data. + + This helper enforces a consistent contract for metrics across + LocalRun and RemoteRun: + + - Metric names must be non-empty strings + - Values must be int or float + + Args: + data: Raw metrics mapping provided by the user + + Returns: + A new dictionary containing only valid metrics. + + Raises: + ValueError: If a metric name is empty or a value has an + unsupported type. + """ + + metrics: dict[str, float | int] = {} + for key, value in data.items(): + if not key: + raise ValueError("Metric name cannot be empty") + if isinstance(value, (int, float)): + metrics[key] = value + else: + raise ValueError(f"Unsupported value type for '{key}': {type(value)}. Currently only int and float are supported.") + + return metrics + + # ---- Artifact validation ----------------------------------------- + + def _validate_artifact_input( + self, + file_path: str, + name: str | None = None, + category: str | None = None, + ) -> tuple[str, str]: + """Validate artifact input parameters. + + This helper enforces a consistent contract for artifact logging + across LocalRun and RemoteRun: + + - File path must be non-empty and point to an existing file + - Category must be one of the allowed values if provided + - Returns absolute file path and artifact name + + Args: + file_path: Path to the artifact file + name: Optional custom name for the artifact + category: Optional category for the artifact + + Returns: + A tuple of (absolute_file_path, artifact_name) + + Raises: + ValueError: If file_path is invalid, file doesn't exist, + or category is invalid. + """ + import os + + # Validate file path + if not file_path: + raise ValueError("File path cannot be empty") + + abs_file_path = os.path.abspath(file_path) + if not os.path.exists(abs_file_path): + raise ValueError(f"File does not exist: {file_path}") + + if not os.path.isfile(abs_file_path): + raise ValueError(f"Path is not a file: {file_path}") + + # Determine artifact name + artifact_name = name or os.path.basename(abs_file_path) + if not artifact_name: + raise ValueError("Artifact name cannot be empty") + + # Validate category if provided + if category is not None and category not in ("code", "model", "config", "data", "other"): + raise ValueError(f"Invalid category: {category}. Must be one of: code, model, config, data, other") + + return abs_file_path, artifact_name diff --git a/src/aspara/run/_config.py b/src/aspara/run/_config.py new file mode 100644 index 0000000000000000000000000000000000000000..03f586a232ff8f948adb427436ea536798c0d23c --- /dev/null +++ b/src/aspara/run/_config.py @@ -0,0 +1,90 @@ +"""Configuration object for run parameters.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +class Config: + """Configuration object that allows attribute-style access to parameters. + + Similar to wandb.config, this class provides a dict-like object that + can be accessed via attributes. + """ + + def __init__(self, data: dict[str, Any] | None = None, on_change: Callable[[], None] | None = None) -> None: + """Initialize config with optional initial data. + + Args: + data: Optional dictionary of initial configuration values + on_change: Optional callback function called when config changes + """ + object.__setattr__(self, "_data", data or {}) + object.__setattr__(self, "_on_change", on_change) + + def __getattr__(self, name: str) -> Any: + """Get config value by attribute name.""" + data = object.__getattribute__(self, "_data") + if name in data: + return data[name] + raise AttributeError(f"Config has no attribute '{name}'") + + def __setattr__(self, name: str, value: Any) -> None: + """Set config value by attribute name.""" + if name.startswith("_"): + object.__setattr__(self, name, value) + else: + self._data[name] = value + self._notify() + + def __getitem__(self, key: str) -> Any: + """Get config value by key.""" + return self._data[key] + + def __setitem__(self, key: str, value: Any) -> None: + """Set config value by key.""" + self._data[key] = value + self._notify() + + def __contains__(self, key: str) -> bool: + """Check if key exists in config.""" + return key in self._data + + def __repr__(self) -> str: + """Return string representation of config.""" + return f"Config({self._data})" + + def keys(self) -> Any: + """Return config keys.""" + return self._data.keys() + + def values(self) -> Any: + """Return config values.""" + return self._data.values() + + def items(self) -> Any: + """Return config items.""" + return self._data.items() + + def update(self, data: dict[str, Any]) -> None: + """Update config with dictionary of values. + + Args: + data: Dictionary of configuration values to update + """ + self._data.update(data) + self._notify() + + def to_dict(self) -> dict[str, Any]: + """Convert config to dictionary. + + Returns: + Dictionary containing all configuration values + """ + return dict(self._data) + + def _notify(self) -> None: + """Notify about config change via callback.""" + if self._on_change: + self._on_change() diff --git a/src/aspara/run/_local_run.py b/src/aspara/run/_local_run.py new file mode 100644 index 0000000000000000000000000000000000000000..cd6b2bf0c2a5187f85cc46c34762a6350e8e3eb4 --- /dev/null +++ b/src/aspara/run/_local_run.py @@ -0,0 +1,371 @@ +"""LocalRun implementation for tracking metrics to the local filesystem.""" + +from __future__ import annotations + +import contextlib +import json +import os +import shutil +from pathlib import Path +from typing import Any + +from aspara.config import get_data_dir +from aspara.exceptions import RunAlreadyExistsError +from aspara.run._base_run import BaseRun +from aspara.run._config import Config +from aspara.run._summary import Summary +from aspara.storage.metrics import create_metrics_storage, resolve_metrics_storage_backend +from aspara.utils.metadata import update_project_metadata_tags +from aspara.utils.timestamp import now_ms, parse_to_ms + + +class LocalRun(BaseRun): + """A local run that stores metrics to the local filesystem.""" + + def __init__( + self, + name: str | None = None, + project: str | None = None, + config: dict[str, Any] | None = None, + tags: list[str] | None = None, + notes: str | None = None, + dir: str | None = None, + storage_backend: str | None = None, + project_tags: list[str] | None = None, + resume: bool = False, + ) -> None: + """Initialize a new local run. + + Args: + name: Name of the run. If None, generates a random name. + project: Project name this run belongs to. Defaults to "default". + config: Initial configuration parameters. + tags: List of tags for this run. + notes: Run notes/description (wandb-compatible). + dir: Base directory for storing data. If None, uses XDG-based default (~/.local/share/aspara). + storage_backend: Storage backend type ('jsonl' or 'polars'). Defaults to 'jsonl'. ASPARA_STORAGE_BACKEND has higher priority than this argument. + project_tags: List of tags to add to the project. + resume: If True and a run with the same name already exists, resume + it (reuse run_id, reset finish state, continue step numbering). + If no existing run is found, a new run is created. + """ + super().__init__(name=name, project=project, tags=tags, notes=notes) + + # Determine data directory + data_dir = dir or str(get_data_dir()) + self._data_dir = data_dir + + # Determine storage backend using central resolver + resolved_backend = resolve_metrics_storage_backend(storage_backend) + + # Build path within data directory (project/run structure) + base_dir = os.path.join(data_dir, self.project) + self._output_path = os.path.join(base_dir, f"{self.name}.jsonl") + + # Set up artifacts directory path + run_dir = os.path.dirname(self._output_path) + self._artifacts_dir = os.path.join(run_dir, self.name, "artifacts") + + # Initialize metrics storage backend + self._storage_backend_type = resolved_backend + self._metrics_storage = create_metrics_storage( + backend=resolved_backend, + base_dir=data_dir, + project_name=self.project, + run_name=self.name, + ) + + # Initialize metadata storage + from aspara.storage import RunMetadataStorage + + self._metadata_storage = RunMetadataStorage( + base_dir=data_dir, + project_name=self.project, + run_name=self.name, + ) + + # Detect existing run by checking for a metadata file with a run_id. + existing_meta_path = Path(base_dir) / f"{self.name}.meta.json" + existing_run_id: str | None = None + if existing_meta_path.exists(): + try: + with open(existing_meta_path) as f: + existing_meta = json.load(f) + existing_run_id = existing_meta.get("run_id") + except (json.JSONDecodeError, OSError): + existing_run_id = None + + is_resuming = resume and existing_run_id is not None + + if is_resuming: + # Reuse the existing run_id and reset finish state. + self.id = existing_run_id + self._metadata_storage.reset_finish() + # Continue step numbering from where the previous session left off. + self._current_step = self._recover_last_step(data_dir) + else: + # New run: generate a fresh run_id. + self.id = self._generate_run_id() + if existing_run_id is not None and not resume: + # A run with the same name already exists and the user did not + # request a resume. Appending would mix metrics from independent + # run instances and corrupt step numbering, so refuse rather + # than silently corrupting data. + raise RunAlreadyExistsError( + f"Run '{self.name}' already exists in project '{self.project}'. " + f"Use resume=True to continue the existing run, or choose a " + f"different run name." + ) + + self.config = Config(config, on_change=self._on_config_change) + self.summary = Summary(on_change=self._on_summary_change) + + # Update project-level metadata tags if provided + update_project_metadata_tags(self._data_dir, self.project, project_tags) + + # User-facing guidance: where data is stored and how to view it. + backend_msg = f" (backend: {self._storage_backend_type})" if self._storage_backend_type == "polars" else "" + storage_location = ( + os.path.abspath(os.path.join(base_dir, f"{self.name}.wal.jsonl")) if self._storage_backend_type == "polars" else os.path.abspath(self._output_path) + ) + resume_msg = " (resumed)" if is_resuming else "" + print(f"aspara: Run '{self.name}' initialized in project '{self.project}'{backend_msg}{resume_msg}") + print(f"aspara: Data directory: {os.path.abspath(data_dir)}") + print(f"aspara: Writing metrics to: {storage_location}") + print("aspara: View results with: aspara dashboard") + + self._ensure_output_dir() + self._write_init_record() + + def _recover_last_step(self, data_dir: str) -> int: + """Recover the next step number from existing metrics. + + Reads the existing metrics file and returns ``max(step) + 1`` so that + resumed logging continues without overwriting earlier rows. Returns 0 + when no prior metrics are present. + + Args: + data_dir: Base data directory. + + Returns: + Next step number to use. + """ + try: + df = self._metrics_storage.load() + except Exception: + return 0 + if df is None or len(df) == 0 or "step" not in df.columns: + return 0 + last_step = df.select("step").to_series().max() + if last_step is None: + return 0 + return int(last_step) + 1 + + def _write_init_record(self) -> None: + """Write initial run record with metadata.""" + timestamp = now_ms() + self._metadata_storage.set_init( + run_id=self.id, + tags=self.tags, + notes=self.notes, + timestamp=timestamp, + ) + + # Write initial config if present + if self.config._data: + self._metadata_storage.update_config(self.config.to_dict()) + + def _on_config_change(self) -> None: + """Callback when config changes.""" + if not self._finished: + self._metadata_storage.update_config(self.config.to_dict()) + + def _on_summary_change(self) -> None: + """Callback when summary changes.""" + if not self._finished: + self._metadata_storage.update_summary(self.summary.to_dict()) + + def log( + self, + data: dict[str, Any], + step: int | None = None, + commit: bool = True, + timestamp: str | None = None, + ) -> None: + """Log metrics and other data. + + This is the main method for logging data during a run. It supports + scalar values (int, float) for now, with future support for images, + tables, etc. + + Args: + data: Dictionary of metric names to values + step: Optional step number. If None, auto-increments. + commit: If True, commits the step. If False, accumulates data. + timestamp: Optional timestamp in ISO format. If None, uses current time. + + Raises: + ValueError: If data contains invalid values + RuntimeError: If run has already finished + """ + self._ensure_not_finished() + + # Prepare step value (mirrors previous behaviour) + self._prepare_step(step, commit) + + # Validate and normalize metrics using shared helper + metrics = self._validate_metrics(data) + + if metrics: + # Generate current time if timestamp is None, otherwise parse + if timestamp is None: + timestamp_ms = now_ms() + else: + timestamp_ms = parse_to_ms(timestamp) + metrics_data = { + "timestamp": timestamp_ms, + "step": self._current_step, + "metrics": metrics, + } + if self._metrics_storage is not None: + self._metrics_storage.save(metrics_data) + + self._after_log(commit) + + def log_artifact( + self, + file_path: str, + name: str | None = None, + description: str | None = None, + category: str | None = None, + ) -> None: + """Log an artifact file for this run. + + Args: + file_path: Path to the file to be logged as an artifact + name: Optional custom name for the artifact. If None, uses the filename. + description: Optional description of the artifact + category: Optional category ('code', 'model', 'config', 'data', 'other') + + Raises: + ValueError: If file_path is invalid or file doesn't exist + OSError: If file cannot be copied to artifacts directory + RuntimeError: If run has already finished + """ + self._ensure_not_finished() + + # Validate input using shared helper + abs_file_path, artifact_name = self._validate_artifact_input(file_path, name, category) + + # Ensure artifacts directory exists + os.makedirs(self._artifacts_dir, exist_ok=True) + + # Copy file to artifacts directory + dest_path = os.path.join(self._artifacts_dir, artifact_name) + source_size = os.path.getsize(abs_file_path) + try: + shutil.copy2(abs_file_path, dest_path) + except OSError as e: + raise OSError(f"Failed to copy artifact file: {e}") from e + + # Verify the copy succeeded by comparing file sizes. A partial copy + # (e.g. disk full mid-write) would leave a truncated file that + # silently passes without this check. + dest_size = os.path.getsize(dest_path) + if dest_size != source_size: + # Remove the corrupted destination so a retry can start clean. + with contextlib.suppress(OSError): + os.remove(dest_path) + raise OSError(f"Artifact copy verification failed: size mismatch (source={source_size}, dest={dest_size})") + + # Get file size + file_size = dest_size + + # Log artifact metadata + artifact_data = { + "name": artifact_name, + "original_path": abs_file_path, + "stored_path": os.path.join("artifacts", artifact_name), + "file_size": file_size, + "timestamp": now_ms(), + } + + if description: + artifact_data["description"] = description + + if category: + artifact_data["category"] = category + + self._metadata_storage.add_artifact(artifact_data) + + def finish(self, exit_code: int = 0, quiet: bool = False, flush_timeout: float = 30.0) -> None: + """Finish the run and write final record. + + Args: + exit_code: Exit code for the run (0 = success) + quiet: If True, suppress output messages + flush_timeout: Unused — LocalRun writes synchronously so there is + no queue to flush. Accepted for signature compatibility with + RemoteRun.finish(). + """ + if not self._mark_finished(): + return + + timestamp = now_ms() + self._metadata_storage.set_finish(exit_code=exit_code, timestamp=timestamp) + + # Finalize metrics storage (e.g., flush WAL to Parquet) + if self._metrics_storage is not None: + self._metrics_storage.finish() + + # Close storage backend connections + if self._metrics_storage is not None: + self._metrics_storage.close() + if self._metadata_storage is not None: + self._metadata_storage.close() + + if not quiet: + print(f"aspara: Run '{self.name}' finished with exit code {exit_code}") + print("aspara: View results with: aspara dashboard") + + def flush(self, timeout: float = 30.0) -> int: + """Ensure all metrics are written to disk. + + LocalRun writes directly to disk on every ``log()`` call, so there + is never any buffered data to flush. This method always returns 0 + (no failures) for API compatibility with ``RemoteRun.flush()``. + + Args: + timeout: Ignored. Accepted for API compatibility with + ``RemoteRun.flush()``. + + Returns: + Number of metrics that failed to persist (always 0 for local runs). + """ + # Currently a no-op as we're writing directly to file + # This method exists for API compatibility and future buffering support + return 0 + + def _ensure_output_dir(self) -> None: + """Ensure the output directory exists.""" + output_dir = os.path.dirname(self._output_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + # Touch the file to ensure it exists and is writable + Path(self._output_path).touch() + + def set_tags(self, tags: list[str]) -> None: + """Set tags for this run. + + Args: + tags: List of tags + + Raises: + RuntimeError: If run has already finished + """ + if self._finished: + raise RuntimeError("Cannot modify a finished run") + + self.tags = tags + self._metadata_storage.set_tags(tags) diff --git a/src/aspara/run/_offline_queue.py b/src/aspara/run/_offline_queue.py new file mode 100644 index 0000000000000000000000000000000000000000..642d4eb01a2c7092cb5a0bf3691a627df668d549 --- /dev/null +++ b/src/aspara/run/_offline_queue.py @@ -0,0 +1,643 @@ +"""Offline queue for RemoteRun metrics when tracker is unavailable. + +This module provides offline queueing capability for metrics when the tracker +server is temporarily unavailable. Metrics are persisted to disk and retried +with exponential backoff when the server becomes available again. +""" + +from __future__ import annotations + +import heapq +import random +import threading +import time +import uuid +from collections.abc import Callable +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, Field, ValidationError + +from aspara.config import get_data_dir +from aspara.logger import logger +from aspara.utils.file import atomic_write_json, atomic_write_text, datasync +from aspara.utils.validators import validate_project_name, validate_run_name + +if TYPE_CHECKING: + from aspara.run._remote_run import TrackerClient + + +class MetricsQueueItem(BaseModel): + """A single queued metrics item awaiting delivery to the tracker.""" + + id: str = Field(default_factory=lambda: uuid.uuid4().hex[:16]) + step: int + metrics: dict[str, Any] + timestamp: str | None = None + created_at: int = Field(default_factory=lambda: int(time.time() * 1000)) + retry_count: int = 0 + next_retry_at: int = 0 + + def to_jsonl(self) -> str: + """Serialize to JSONL format.""" + return self.model_dump_json() + + @classmethod + def from_jsonl(cls, line: str) -> MetricsQueueItem: + """Deserialize from JSONL format.""" + return cls.model_validate_json(line) + + +class QueueMetadata(BaseModel): + """Metadata for a queue file, stored separately.""" + + tracker_uri: str + project: str + run_name: str + run_id: str + created_at: int = Field(default_factory=lambda: int(time.time() * 1000)) + + +# Backoff configuration +_BASE_DELAY_SECONDS = 1.0 +_MAX_DELAY_SECONDS = 300.0 +_JITTER_FACTOR = 0.1 + +# Resource limits +_MAX_QUEUE_ITEMS = 10_000 +_MAX_QUEUE_FILE_SIZE = 100 * 1024 * 1024 # 100MB + +# Health check cache duration +_HEALTH_CHECK_CACHE_SECONDS = 30 + + +def _calculate_backoff_delay(retry_count: int) -> float: + """Calculate exponential backoff delay with jitter. + + Args: + retry_count: Number of retries so far + + Returns: + Delay in seconds before next retry + """ + delay = min(_BASE_DELAY_SECONDS * (2**retry_count), _MAX_DELAY_SECONDS) + jitter = delay * _JITTER_FACTOR * (2 * random.random() - 1) + return delay + jitter + + +class OfflineQueueStorage: + """Manages persistent storage of queued metrics items. + + Queue files are stored at: + {data_dir}/.queue/{project}/{run_name}.queue.jsonl + {data_dir}/.queue/{project}/{run_name}.queue.meta.json + """ + + def __init__( + self, + project: str, + run_name: str, + run_id: str, + tracker_uri: str, + data_dir: Path | None = None, + ) -> None: + """Initialize queue storage. + + Args: + project: Project name + run_name: Run name + run_id: Run ID + tracker_uri: Tracker server URI + data_dir: Base data directory (defaults to get_data_dir()) + """ + self.project = project + self.run_name = run_name + self.run_id = run_id + self.tracker_uri = tracker_uri + self.data_dir = data_dir or get_data_dir() + + self._lock = threading.Lock() + self._item_count = 0 + + # Validate names to prevent path traversal attacks + # Using the same validators as the rest of the codebase for consistency + validate_project_name(project) + validate_run_name(run_name) + + # Paths (names are now validated, safe to use directly) + self._queue_dir = self.data_dir / ".queue" / project + self._queue_file = self._queue_dir / f"{run_name}.queue.jsonl" + self._meta_file = self._queue_dir / f"{run_name}.queue.meta.json" + + # Initialize directories and metadata + self._ensure_initialized() + + def _ensure_initialized(self) -> None: + """Ensure queue directory and metadata file exist.""" + self._queue_dir.mkdir(parents=True, exist_ok=True) + + # Write metadata file if it doesn't exist, or validate it if it does. + if not self._meta_file.exists(): + self._write_metadata() + else: + self._validate_metadata_file() + + # Count existing items + if self._queue_file.exists(): + try: + with self._queue_file.open("r") as f: + self._item_count = sum(1 for line in f if line.strip()) + except OSError: + self._item_count = 0 + + def _validate_metadata_file(self) -> None: + """Load and validate the metadata file. + + If the file is corrupted or has mismatched project/run_name, log a + warning and overwrite it with correct metadata so the queue can + continue operating instead of silently ignoring the mismatch. + """ + try: + raw = self._meta_file.read_text() + metadata = QueueMetadata.model_validate_json(raw) + except (OSError, ValueError, ValidationError) as e: + logger.warning(f"Queue metadata file is corrupted, rewriting: {e}") + self._write_metadata() + return + + # Verify the metadata matches this queue's identity. A mismatch could + # happen if the queue directory was manually moved or copied. + if metadata.project != self.project or metadata.run_name != self.run_name: + logger.warning( + f"Queue metadata mismatch: file has project={metadata.project!r} " + f"run_name={metadata.run_name!r}, expected project={self.project!r} " + f"run_name={self.run_name!r}. Rewriting metadata." + ) + self._write_metadata() + + def _write_metadata(self) -> None: + """Write the metadata file with current queue identity. + + Uses atomic write to prevent corruption on crash. + """ + metadata = QueueMetadata( + tracker_uri=self.tracker_uri, + project=self.project, + run_name=self.run_name, + run_id=self.run_id, + ) + atomic_write_json(self._meta_file, metadata.model_dump()) + + def enqueue(self, item: MetricsQueueItem) -> bool: + """Add an item to the queue. + + Args: + item: Queue item to add + + Returns: + True if item was added, False if queue is full + """ + with self._lock: + # Check resource limits + if self._item_count >= _MAX_QUEUE_ITEMS: + logger.warning(f"Offline queue full ({_MAX_QUEUE_ITEMS} items). Dropping oldest metrics.") + self._drop_oldest_items(count=100) + + if self._queue_file.exists(): + file_size = self._queue_file.stat().st_size + if file_size >= _MAX_QUEUE_FILE_SIZE: + logger.warning(f"Offline queue file too large ({file_size / 1024 / 1024:.1f}MB). Dropping oldest metrics.") + self._drop_oldest_items(count=100) + + try: + with self._queue_file.open("a") as f: + f.write(item.to_jsonl() + "\n") + f.flush() + datasync(f.fileno()) + self._item_count += 1 + return True + except OSError as e: + logger.warning(f"Failed to write to offline queue: {e}") + return False + + def _drop_oldest_items(self, count: int) -> None: + """Drop the oldest items from the queue. + + Must be called with lock held. + + Args: + count: Number of items to drop + """ + if not self._queue_file.exists(): + return + + try: + with self._queue_file.open("r") as f: + lines = f.readlines() + + remaining = lines[count:] + atomic_write_text(self._queue_file, lambda f: f.writelines(remaining), suffix=".jsonl") + + self._item_count = len(remaining) + except OSError as e: + logger.warning(f"Failed to drop oldest items from queue: {e}") + + @staticmethod + def _parse_queue_item(line: str) -> MetricsQueueItem | None: + """Parse a JSONL queue item, logging and returning None on failure.""" + try: + return MetricsQueueItem.from_jsonl(line) + except (ValueError, ValidationError) as e: + logger.debug(f"Skipping invalid queue item: {e}") + return None + + def get_ready_items(self, limit: int = 100) -> list[MetricsQueueItem]: + """Get items ready for retry, sorted by step. + + Uses a bounded heap so that at most ``limit`` items are held in + memory at any time, even if the queue file is very large. + + Args: + limit: Maximum number of items to return + + Returns: + List of items ready for retry + """ + now_ms = int(time.time() * 1000) + # Min-heap of (sort_key, item) — we push negative sort keys to use + # heapq as a max-heap of size ``limit``, then pop all and reverse. + # This keeps memory usage bounded to ``limit`` items. + heap: list[tuple[tuple[int, int], MetricsQueueItem]] = [] + + with self._lock: + if not self._queue_file.exists(): + return [] + + try: + with self._queue_file.open("r") as f: + for line in f: + stripped = line.strip() + if not stripped: + continue + item = self._parse_queue_item(stripped) + if item is None: + continue + + if item.next_retry_at > now_ms: + continue + + sort_key = (item.step, item.created_at) + # Use negative key for max-heap behavior: heap[0] + # holds the item with the LARGEST original key, so + # it gets evicted first when a smaller item arrives. + neg_key = (-sort_key[0], -sort_key[1]) + if len(heap) < limit: + heapq.heappush(heap, (neg_key, item)) + elif neg_key > heap[0][0]: + heapq.heapreplace(heap, (neg_key, item)) + except OSError as e: + logger.warning(f"Failed to read queue file {self._queue_file}: {e}") + + # Extract items from heap in sorted order (ascending by step, then created_at). + result = [heapq.heappop(heap)[1] for _ in range(len(heap))] + result.reverse() + return result + + def dequeue(self, item_ids: list[str]) -> int: + """Remove items from the queue by ID. + + Args: + item_ids: List of item IDs to remove + + Returns: + Number of items removed + """ + if not item_ids: + return 0 + + ids_set = set(item_ids) + removed = 0 + + with self._lock: + if not self._queue_file.exists(): + return 0 + + try: + with self._queue_file.open("r") as f: + lines = f.readlines() + + remaining_lines: list[str] = [] + for line in lines: + if not line.strip(): + continue + item = self._parse_queue_item(line.strip()) + if item is not None and item.id in ids_set: + removed += 1 + continue + remaining_lines.append(line if line.endswith("\n") else line + "\n") + + atomic_write_text(self._queue_file, lambda f: f.writelines(remaining_lines), suffix=".jsonl") + + self._item_count = len(remaining_lines) + except OSError as e: + logger.warning(f"Failed to dequeue items: {e}") + + return removed + + def update_retry_info(self, item_id: str, retry_count: int, next_retry_at: int) -> bool: + """Update retry information for an item. + + Args: + item_id: ID of the item to update + retry_count: New retry count + next_retry_at: Timestamp for next retry (ms) + + Returns: + True if item was updated, False if not found + """ + with self._lock: + if not self._queue_file.exists(): + return False + + try: + with self._queue_file.open("r") as f: + lines = f.readlines() + + updated = False + new_lines: list[str] = [] + for line in lines: + if not line.strip(): + continue + item = self._parse_queue_item(line.strip()) + if item is not None and item.id == item_id: + item.retry_count = retry_count + item.next_retry_at = next_retry_at + new_lines.append(item.to_jsonl() + "\n") + updated = True + continue + new_lines.append(line if line.endswith("\n") else line + "\n") + + if updated: + atomic_write_text(self._queue_file, lambda f: f.writelines(new_lines), suffix=".jsonl") + + return updated + except OSError as e: + logger.warning(f"Failed to update retry info: {e}") + return False + + @property + def queue_dir(self) -> Path: + """Return the queue directory path.""" + return self._queue_dir + + def is_empty(self) -> bool: + """Check if the queue is empty.""" + with self._lock: + return self._item_count == 0 + + def count(self) -> int: + """Get the number of items in the queue.""" + with self._lock: + return self._item_count + + def cleanup(self) -> None: + """Remove queue files if empty.""" + with self._lock: + if self._item_count == 0: + try: + if self._queue_file.exists(): + self._queue_file.unlink() + if self._meta_file.exists(): + self._meta_file.unlink() + # Try to remove empty directory + if self._queue_dir.exists() and not any(self._queue_dir.iterdir()): + self._queue_dir.rmdir() + except OSError as e: + logger.debug(f"Failed to cleanup queue files at {self._queue_dir}: {e}") + + +class MetricsRetryWorker: + """Background worker that retries sending queued metrics. + + The worker periodically checks the queue and attempts to send metrics + to the tracker. Uses exponential backoff for retries and caches + health check results to reduce load on the tracker. + """ + + def __init__( + self, + storage: OfflineQueueStorage, + client: TrackerClient, + project: str, + run_name: str, + send_callback: Callable[[int, dict[str, Any], str | None], bool] | None = None, + ) -> None: + """Initialize the retry worker. + + Args: + storage: Queue storage instance + client: Tracker client for sending metrics + project: Project name + run_name: Run name + send_callback: Optional callback for sending metrics. If provided, + called instead of client.save_metrics. Signature: + (step, metrics, timestamp) -> success + """ + self.storage = storage + self.client = client + self.project = project + self.run_name = run_name + self._send_callback = send_callback + + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._poll_interval = 5.0 # seconds + + # Health check cache + self._last_health_check: float = 0 + self._last_health_result: bool = False + + def start(self) -> None: + """Start the background worker thread.""" + if self._thread is not None and self._thread.is_alive(): + return + + self._stop_event.clear() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + logger.debug("MetricsRetryWorker started") + + def stop(self, timeout: float = 5.0) -> None: + """Stop the background worker thread. + + Args: + timeout: Maximum time to wait for thread to stop + """ + self._stop_event.set() + if self._thread is not None and self._thread.is_alive(): + self._thread.join(timeout=timeout) + self._thread = None + logger.debug("MetricsRetryWorker stopped") + + def _run(self) -> None: + """Main worker loop.""" + while not self._stop_event.is_set(): + try: + self._process_queue() + except Exception as e: + logger.debug(f"Error in retry worker: {e}") + + # Wait for next poll or stop signal + self._stop_event.wait(timeout=self._poll_interval) + + def _check_tracker_health(self, force: bool = False) -> bool: + """Check if the tracker is available. + + Uses cached result if recent enough to reduce load. + + Args: + force: If True, ignore cache and check immediately + + Returns: + True if tracker is available + """ + now = time.time() + if not force and (now - self._last_health_check) < _HEALTH_CHECK_CACHE_SECONDS: + return self._last_health_result + + try: + result = self.client.health_check() + self._last_health_result = result + self._last_health_check = now + return result + except Exception: + self._last_health_result = False + self._last_health_check = now + return False + + def _mark_unavailable(self) -> None: + """Mark tracker as unavailable (called on send failure).""" + self._last_health_result = False + self._last_health_check = time.time() + + def _mark_available(self) -> None: + """Mark tracker as available (called on send success).""" + self._last_health_result = True + self._last_health_check = time.time() + + def _process_queue(self) -> None: + """Process ready items from the queue.""" + if self.storage.is_empty(): + return + + if not self._check_tracker_health(): + return + + items = self.storage.get_ready_items(limit=50) + if not items: + return + + sent_ids: list[str] = [] + for item in items: + success = self._send_item(item) + if success: + sent_ids.append(item.id) + logger.info(f"Queued metrics sent successfully (step={item.step})") + else: + # Update retry info with backoff + new_retry_count = item.retry_count + 1 + delay_ms = int(_calculate_backoff_delay(new_retry_count) * 1000) + next_retry = int(time.time() * 1000) + delay_ms + self.storage.update_retry_info(item.id, new_retry_count, next_retry) + logger.debug(f"Retry failed for queued metrics (step={item.step}, retry={new_retry_count}, next_retry_in={delay_ms / 1000:.1f}s)") + self._mark_unavailable() + break # Stop processing on first failure + + if sent_ids: + self.storage.dequeue(sent_ids) + + def _send_item(self, item: MetricsQueueItem) -> bool: + """Attempt to send a single item. + + Args: + item: Queue item to send + + Returns: + True if sent successfully + """ + try: + if self._send_callback is not None: + return self._send_callback(item.step, item.metrics, item.timestamp) + else: + self.client.save_metrics( + project=self.project, + run_name=self.run_name, + step=item.step, + metrics=item.metrics, + timestamp=item.timestamp, + ) + self._mark_available() + return True + except Exception as e: + logger.debug(f"Failed to send queued metrics: {e}") + return False + + def flush_sync(self, timeout: float = 30.0) -> int: + """Synchronously flush all items in the queue. + + Attempts to send all queued items, blocking until done or timeout. + Used during finish() to ensure all metrics are sent before exit. + + Args: + timeout: Maximum time to wait in seconds + + Returns: + Number of items that failed to send + """ + start_time = time.time() + failed_count = 0 + + logger.info("Flushing offline queue...") + + while time.time() - start_time < timeout: + if self.storage.is_empty(): + break + + items = self.storage.get_ready_items(limit=100) + if not items: + # All items have future retry times, wait a bit + time.sleep(0.5) + continue + + sent_ids: list[str] = [] + for item in items: + if time.time() - start_time >= timeout: + break + + success = self._send_item(item) + if success: + sent_ids.append(item.id) + else: + # On failure, update retry info but continue trying + new_retry_count = item.retry_count + 1 + delay_ms = int(_calculate_backoff_delay(new_retry_count) * 1000) + next_retry = int(time.time() * 1000) + delay_ms + self.storage.update_retry_info(item.id, new_retry_count, next_retry) + + # If health check fails, stop trying + if not self._check_tracker_health(force=True): + break + + if sent_ids: + self.storage.dequeue(sent_ids) + + # Count remaining items as failed + failed_count = self.storage.count() + if failed_count > 0: + logger.warning(f"Offline queue flush timed out. {failed_count} metrics remain unsent. Queue files preserved at: {self.storage.queue_dir}") + else: + # Clean up empty queue files + self.storage.cleanup() + logger.info("Offline queue flushed successfully") + + return failed_count diff --git a/src/aspara/run/_remote_run.py b/src/aspara/run/_remote_run.py new file mode 100644 index 0000000000000000000000000000000000000000..2ffd028e7d2266c328837b270eed95651c358bf6 --- /dev/null +++ b/src/aspara/run/_remote_run.py @@ -0,0 +1,523 @@ +"""RemoteRun implementation for tracking metrics via HTTP to Aspara tracker.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from urllib.parse import quote + +from aspara.logger import logger +from aspara.run._base_run import BaseRun +from aspara.run._config import Config +from aspara.run._offline_queue import MetricsQueueItem, MetricsRetryWorker, OfflineQueueStorage +from aspara.run._summary import Summary + +if TYPE_CHECKING: + import requests +else: + try: + import requests + except ImportError: + requests: Any = None # Will raise error on RemoteRun instantiation + +# Default timeout for HTTP requests in seconds +_DEFAULT_TIMEOUT = 30.0 + + +class TrackerClient: + """HTTP client for communicating with Aspara tracker.""" + + def __init__(self, base_url: str) -> None: + """Initialize tracker client. + + Args: + base_url: Base URL of the tracker server (e.g., "http://localhost:3142") + + Raises: + ImportError: If requests library is not installed + """ + if requests is None: + raise ImportError('requests library is required for RemoteRun. Install it with: pip install "aspara[remote]"') + + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + + # Set X-Requested-With header for CSRF protection on all requests + self.session.headers.update({"X-Requested-With": "XMLHttpRequest"}) + + def close(self) -> None: + """Close the HTTP session and release connection pool resources.""" + self.session.close() + + def __enter__(self) -> TrackerClient: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def create_run( + self, + name: str, + project: str, + config: dict[str, Any] | None, + tags: list[str] | None, + notes: str | None, + project_tags: list[str] | None = None, + resume: bool = False, + ) -> dict[str, Any]: + """Create a new run on the tracker. + + Args: + name: Run name + project: Project name + config: Configuration parameters + tags: List of tags + notes: Run notes/description + project_tags: Optional project-level tags + resume: If True, resume an existing run with the same name + + Returns: + Parsed JSON response from the tracker (expected to contain + ``run_id`` and other metadata). + + Raises: + requests.RequestException: If HTTP request fails. + """ + response = self.session.post( + f"{self.base_url}/api/v1/projects/{quote(project, safe='')}/runs", + json={ + "name": name, + "config": config or {}, + "tags": tags or [], + "notes": notes or "", + "project_tags": project_tags, + "resume": resume, + }, + timeout=_DEFAULT_TIMEOUT, + ) + response.raise_for_status() + return response.json() + + def save_metrics( + self, + project: str, + run_name: str, + step: int, + metrics: dict[str, Any], + timestamp: str | None = None, + ) -> dict[str, Any]: + """Save metrics for a specific run. + + Args: + project: Project name + run_name: Run name + step: Step number + metrics: Dictionary of metric names to values + timestamp: Optional timestamp in ISO format. If None, server will + assign the timestamp. + + Raises: + requests.RequestException: If HTTP request fails + """ + + payload: dict[str, Any] = { + "step": step, + "metrics": metrics, + } + if timestamp is not None: + payload["timestamp"] = timestamp + + response = self.session.post( + f"{self.base_url}/api/v1/projects/{quote(project, safe='')}/runs/{quote(run_name, safe='')}/metrics", + json=payload, + timeout=_DEFAULT_TIMEOUT, + ) + response.raise_for_status() + return response.json() + + def log_config(self, project: str, run_name: str, config: dict[str, Any]) -> None: + """Log config update to the tracker. + + Args: + project: Project name + run_name: Run name + config: Configuration parameters + + Raises: + requests.RequestException: If HTTP request fails + """ + response = self.session.post( + f"{self.base_url}/api/v1/projects/{quote(project, safe='')}/runs/{quote(run_name, safe='')}/config", + json={"config": config}, + timeout=_DEFAULT_TIMEOUT, + ) + response.raise_for_status() + + def log_summary(self, project: str, run_name: str, summary: dict[str, Any]) -> None: + """Log summary data to the tracker. + + Args: + project: Project name + run_name: Run name + summary: Summary data + + Raises: + requests.RequestException: If HTTP request fails + """ + response = self.session.post( + f"{self.base_url}/api/v1/projects/{quote(project, safe='')}/runs/{quote(run_name, safe='')}/summary", + json={"summary": summary}, + timeout=_DEFAULT_TIMEOUT, + ) + response.raise_for_status() + + def finish_run(self, project: str, run_name: str, exit_code: int) -> None: + """Finish the run on the tracker. + + Args: + project: Project name + run_name: Run name + exit_code: Exit code (0 = success) + + Raises: + requests.RequestException: If HTTP request fails + """ + response = self.session.post( + f"{self.base_url}/api/v1/projects/{quote(project, safe='')}/runs/{quote(run_name, safe='')}/finish", + json={"exit_code": exit_code}, + timeout=_DEFAULT_TIMEOUT, + ) + response.raise_for_status() + + def update_tags(self, project: str, run_name: str, tags: list[str]) -> None: + """Update tags for a run on the tracker. + + Args: + project: Project name + run_name: Run name + tags: New list of tags (replaces existing tags) + + Raises: + requests.RequestException: If HTTP request fails + """ + response = self.session.post( + f"{self.base_url}/api/v1/projects/{quote(project, safe='')}/runs/{quote(run_name, safe='')}/tags", + json={"tags": tags}, + timeout=_DEFAULT_TIMEOUT, + ) + response.raise_for_status() + + def health_check(self, timeout: float = 5.0) -> bool: + """Check if the tracker server is healthy. + + Args: + timeout: Request timeout in seconds + + Returns: + True if the server is healthy, False otherwise + """ + try: + response = self.session.get( + f"{self.base_url}/api/v1/health", + timeout=timeout, + ) + return response.status_code == 200 + except requests.RequestException as e: + logger.debug(f"Health check failed: {e}") + return False + + def log_artifact( + self, + project: str, + run_name: str, + file_path: str, + name: str | None = None, + description: str | None = None, + category: str | None = None, + ) -> dict[str, Any]: + """Upload an artifact file to the tracker. + + Args: + project: Project name + run_name: Run name + file_path: Path to the file to upload + name: Optional custom name for the artifact. If None, uses the filename. + description: Optional description of the artifact + category: Optional category ('code', 'model', 'config', 'data', 'other') + + Returns: + Parsed JSON response from the tracker + + Raises: + requests.RequestException: If HTTP request fails + """ + import os + + # Prepare multipart form data + with open(file_path, "rb") as f: + files = {"file": (os.path.basename(file_path), f)} + data = {} + if name: + data["name"] = name + if description: + data["description"] = description + if category: + data["category"] = category + + response = self.session.post( + f"{self.base_url}/api/v1/projects/{quote(project, safe='')}/runs/{quote(run_name, safe='')}/artifacts", + files=files, + data=data, + timeout=_DEFAULT_TIMEOUT, + ) + response.raise_for_status() + return response.json() + + +class RemoteRun(BaseRun): + """A run that sends metrics to a remote Aspara tracker via HTTP.""" + + def __init__( + self, + name: str | None = None, + project: str | None = None, + config: dict[str, Any] | None = None, + tags: list[str] | None = None, + notes: str | None = None, + tracker_uri: str | None = None, + project_tags: list[str] | None = None, + resume: bool = False, + ) -> None: + """Initialize a new remote run. + + Args: + name: Name of the run. If None, server will generate one. + project: Project name this run belongs to. Defaults to "default". + config: Initial configuration parameters. + tags: List of tags for this run. + notes: Run notes/description (wandb-compatible). + tracker_uri: Tracker server URI (required for RemoteRun) + project_tags: List of tags to add to the project. + resume: If True and a run with the same name already exists on the + tracker, resume it (reuse run_id, reset finish state). If no + existing run is found, a new run is created. + + Raises: + ValueError: If tracker_uri is not provided + ImportError: If requests library is not installed + """ + if tracker_uri is None: + raise ValueError("tracker_uri is required for RemoteRun") + + super().__init__(name=name, project=project, tags=tags, notes=notes) + + # Initialize tracker client + self.client = TrackerClient(tracker_uri) + + # Create (or resume) run on tracker + try: + response = self.client.create_run( + name=self.name, + project=self.project, + config=config, + tags=self.tags, + notes=self.notes, + project_tags=project_tags, + resume=resume, + ) + # Server always generates run_id + self.id = response["run_id"] + # Update name if server generated one + if "name" in response: + self.name = response["name"] + except requests.RequestException as e: + raise RuntimeError(f"Failed to create run on tracker: {e}") from e + + # Create config with sync callback + def sync_config() -> None: + try: + self.client.log_config(self.project, self.name, self.config.to_dict()) + except requests.RequestException as e: + logger.warning(f"Failed to sync config to tracker: {e}") + + self.config = Config(config, on_change=sync_config) + + # Create summary with sync callback + def sync_summary() -> None: + try: + self.client.log_summary(self.project, self.name, self.summary.to_dict()) + except requests.RequestException as e: + logger.warning(f"Failed to sync summary to tracker: {e}") + + self.summary = Summary(on_change=sync_summary) + + # Initialize offline queue for resilience + self._tracker_uri = tracker_uri + self._queue_storage = OfflineQueueStorage( + project=self.project, + run_name=self.name, + run_id=self.id, + tracker_uri=tracker_uri, + ) + self._retry_worker = MetricsRetryWorker( + storage=self._queue_storage, + client=self.client, + project=self.project, + run_name=self.name, + ) + self._retry_worker.start() + + # User-facing guidance: where data is being sent and how to view it. + print(f"aspara: Run '{self.name}' initialized in project '{self.project}'") + print(f"aspara: Sending metrics to: {tracker_uri}") + print("aspara: View results with: aspara dashboard") + + def log( + self, + data: dict[str, Any], + step: int | None = None, + commit: bool = True, + timestamp: str | None = None, + ) -> None: + """Log metrics and other data. + + Args: + data: Dictionary of metric names to values + step: Optional step number. If None, auto-increments. + commit: If True, commits the step. If False, accumulates data. + timestamp: Optional timestamp in ISO 8601 format. If provided, it is + forwarded to the tracker; otherwise, the tracker assigns the + timestamp on the server side. + + Raises: + ValueError: If data contains invalid values + RuntimeError: If run has already finished + """ + self._ensure_not_finished() + + # Prepare step value (mirrors LocalRun behaviour) + self._prepare_step(step, commit) + + # Validate and normalize metrics using shared helper + metrics = self._validate_metrics(data) + + if metrics: + try: + self.client.save_metrics( + project=self.project, + run_name=self.name, + step=self._current_step, + metrics=metrics, + timestamp=timestamp, + ) + except requests.RequestException as e: + logger.warning(f"Failed to log metrics to tracker: {e}. Queueing for retry.") + # Queue for later retry + item = MetricsQueueItem( + step=self._current_step, + metrics=metrics, + timestamp=timestamp, + ) + self._queue_storage.enqueue(item) + + self._after_log(commit) + + def finish(self, exit_code: int = 0, quiet: bool = False, flush_timeout: float = 30.0) -> None: + """Finish the run and notify tracker. + + Args: + exit_code: Exit code for the run (0 = success) + quiet: If True, suppress output messages + flush_timeout: Maximum time to wait for queue flush in seconds + """ + if not self._mark_finished(): + return + + # Stop the background worker + self._retry_worker.stop() + + # Flush any remaining queued metrics + if not self._queue_storage.is_empty(): + self._retry_worker.flush_sync(timeout=flush_timeout) + + try: + self.client.finish_run(self.project, self.name, exit_code) + except requests.RequestException as e: + logger.warning(f"Failed to finish run on tracker: {e}") + finally: + self.client.close() + + if not quiet: + print(f"aspara: Run '{self.name}' finished with exit code {exit_code}") + print("aspara: View results with: aspara dashboard") + + def log_artifact( + self, + file_path: str, + name: str | None = None, + description: str | None = None, + category: str | None = None, + ) -> None: + """Log an artifact file for this run. + + Args: + file_path: Path to the file to be logged as an artifact + name: Optional custom name for the artifact. If None, uses the filename. + description: Optional description of the artifact + category: Optional category ('code', 'model', 'config', 'data', 'other') + + Raises: + ValueError: If file_path is invalid or file doesn't exist + RuntimeError: If run has already finished + """ + self._ensure_not_finished() + + # Validate input using shared helper + abs_file_path, artifact_name = self._validate_artifact_input(file_path, name, category) + + # Upload artifact to tracker + try: + self.client.log_artifact( + project=self.project, + run_name=self.name, + file_path=abs_file_path, + name=artifact_name, + description=description, + category=category, + ) + except (requests.RequestException, OSError) as e: + logger.warning(f"Failed to upload artifact to tracker: {e}") + + def flush(self, timeout: float = 30.0) -> int: + """Ensure all metrics are sent to tracker. + + Flushes any queued metrics that failed to send previously. + + Args: + timeout: Maximum time to wait for flush in seconds + + Returns: + Number of metrics that failed to send + """ + if self._queue_storage.is_empty(): + return 0 + return self._retry_worker.flush_sync(timeout=timeout) + + def set_tags(self, tags: list[str]) -> None: + """Set tags for this run. + + Replaces the existing run-level tags with the provided list. + + Args: + tags: List of tags + + Raises: + RuntimeError: If run has already finished + """ + if self._finished: + raise RuntimeError("Cannot modify a finished run") + + self.tags = tags + try: + self.client.update_tags(self.project, self.name, tags) + except Exception as e: + logger.warning(f"Failed to sync tags to tracker: {e}") diff --git a/src/aspara/run/_summary.py b/src/aspara/run/_summary.py new file mode 100644 index 0000000000000000000000000000000000000000..0d799c947187a286d6bec80cc3a5c229e9b3b4c9 --- /dev/null +++ b/src/aspara/run/_summary.py @@ -0,0 +1,62 @@ +"""Summary object for storing final run results.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +class Summary: + """Summary object for storing final run results. + + Similar to wandb.run.summary, this class stores the final values + for a run (e.g., best accuracy, final loss). + """ + + def __init__(self, on_change: Callable[[], None] | None = None) -> None: + """Initialize summary with optional callback. + + Args: + on_change: Optional callback function called when summary changes + """ + self._on_change = on_change + self._data: dict[str, Any] = {} + + def __getitem__(self, key: str) -> Any: + """Get summary value by key.""" + return self._data[key] + + def __setitem__(self, key: str, value: Any) -> None: + """Set summary value by key and write to file.""" + self._data[key] = value + self._notify() + + def __contains__(self, key: str) -> bool: + """Check if key exists in summary.""" + return key in self._data + + def __repr__(self) -> str: + """Return string representation of summary.""" + return f"Summary({self._data})" + + def update(self, data: dict[str, Any]) -> None: + """Update summary with dictionary of values. + + Args: + data: Dictionary of summary values to update + """ + self._data.update(data) + self._notify() + + def to_dict(self) -> dict[str, Any]: + """Convert summary to dictionary. + + Returns: + Dictionary containing all summary values + """ + return dict(self._data) + + def _notify(self) -> None: + """Notify about summary change via callback.""" + if self._on_change: + self._on_change() diff --git a/src/aspara/run/run.py b/src/aspara/run/run.py new file mode 100644 index 0000000000000000000000000000000000000000..07f023205bc308b77e4948bafc1b52289a378d00 --- /dev/null +++ b/src/aspara/run/run.py @@ -0,0 +1,227 @@ +"""Run class with composition pattern for backend delegation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from aspara.run._config import Config + from aspara.run._local_run import LocalRun + from aspara.run._remote_run import RemoteRun + from aspara.run._summary import Summary + + +class Run: + """Run class that delegates to either LocalRun or RemoteRun. + + This class provides a unified interface for creating runs. When tracker_uri is provided, + it creates and delegates to a RemoteRun instance. Otherwise, it creates and delegates + to a LocalRun instance. + """ + + __slots__ = ("_backend",) + _backend: LocalRun | RemoteRun + + def __init__( + self, + name: str | None = None, + project: str | None = None, + config: dict[str, Any] | None = None, + tags: list[str] | None = None, + notes: str | None = None, + dir: str | None = None, + storage_backend: str | None = None, + tracker_uri: str | None = None, + project_tags: list[str] | None = None, + resume: bool = False, + ) -> None: + """Create a new run instance. + + Args: + name: Name of the run. If None, generates a random name. + project: Project name this run belongs to. Defaults to "default". + config: Initial configuration parameters. + tags: List of tags for this run. + notes: Run notes/description (wandb-compatible). + dir: Base directory for storing data. If None, uses XDG-based default (~/.local/share/aspara). + storage_backend: Storage backend type ('jsonl' or 'polars'). Defaults to 'jsonl'. + tracker_uri: Tracker server URI for remote mode. If None, uses local file storage. + project_tags: List of tags to add to the project. + resume: If True and a run with the same name already exists, resume + it (reuse run_id, reset finish state, continue step numbering). + If no existing run is found, a new run is created. + """ + if tracker_uri is not None: + from aspara.run._remote_run import RemoteRun + + self._backend = RemoteRun( + name=name, + project=project, + config=config, + tags=tags, + notes=notes, + tracker_uri=tracker_uri, + project_tags=project_tags, + resume=resume, + ) + else: + from aspara.run._local_run import LocalRun + + self._backend = LocalRun( + name=name, + project=project, + config=config, + tags=tags, + notes=notes, + dir=dir, + storage_backend=storage_backend, + project_tags=project_tags, + resume=resume, + ) + + # ---- Property delegations ---------------------------------------- + + @property + def id(self) -> str: + """Unique run identifier.""" + return self._backend.id + + @property + def name(self) -> str: + """Run name.""" + return self._backend.name + + @property + def project(self) -> str: + """Project name this run belongs to.""" + return self._backend.project + + @property + def tags(self) -> list[str]: + """List of tags for this run.""" + return self._backend.tags + + @property + def notes(self) -> str: + """Run notes/description.""" + return self._backend.notes + + @property + def config(self) -> Config: + """Configuration parameters.""" + return self._backend.config + + @property + def summary(self) -> Summary: + """Run summary data.""" + return self._backend.summary + + @property + def _finished(self) -> bool: + """Whether the run has finished.""" + return self._backend._finished + + @property + def _current_step(self) -> int: + """Current step number.""" + return self._backend._current_step + + # ---- Method delegations ---------------------------------------- + + def log( + self, + data: dict[str, Any], + step: int | None = None, + commit: bool = True, + timestamp: str | None = None, + ) -> None: + """Log metrics and other data. + + Args: + data: Dictionary of metric names to values + step: Optional step number. If None, auto-increments. + commit: If True, commits the step. If False, accumulates data. + timestamp: Optional timestamp in ISO format. If None, uses current time. + """ + self._backend.log(data, step=step, commit=commit, timestamp=timestamp) + + def finish(self, exit_code: int = 0, quiet: bool = False, flush_timeout: float = 30.0) -> None: + """Finish the run. + + Args: + exit_code: Exit code for the run (0 = success) + quiet: If True, suppress output messages + flush_timeout: Maximum time to wait for queue flush in seconds. + Only meaningful for remote runs; ignored by LocalRun. + """ + self._backend.finish(exit_code=exit_code, quiet=quiet, flush_timeout=flush_timeout) + + def flush(self, timeout: float = 30.0) -> int: + """Ensure all data is persisted. + + For LocalRun this is a no-op (data is written directly to disk on + every ``log()`` call) and always returns 0. For RemoteRun this + flushes any queued metrics that failed to send previously. + + Args: + timeout: Maximum time to wait for flush in seconds + (only meaningful for remote runs). + + Returns: + Number of metrics that failed to persist (always 0 for local runs). + """ + return self._backend.flush(timeout=timeout) + + def log_artifact( + self, + file_path: str, + name: str | None = None, + description: str | None = None, + category: str | None = None, + ) -> None: + """Log an artifact file for this run. + + Args: + file_path: Path to the file to be logged as an artifact + name: Optional custom name for the artifact. If None, uses the filename. + description: Optional description of the artifact + category: Optional category ('code', 'model', 'config', 'data', 'other') + """ + self._backend.log_artifact(file_path, name=name, description=description, category=category) + + def set_tags(self, tags: list[str]) -> None: + """Set tags for this run. + + Replaces the existing run-level tags with the provided list. + + Args: + tags: List of tags + """ + self._backend.set_tags(tags) + + # ---- Context manager protocol ---------------------------------------- + + def __enter__(self) -> Run: + """Enter context manager.""" + self._backend.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> bool: + """Exit context manager, ensuring run is finished.""" + return self._backend.__exit__(exc_type, exc_val, exc_tb) + + # ---- Testing utilities ---------------------------------------- + + @property + def backend(self) -> LocalRun | RemoteRun: + """Access to the underlying backend implementation. + + This property is intended for testing purposes to verify + which backend type was created. + """ + return self._backend diff --git a/src/aspara/server/__init__.py b/src/aspara/server/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f7d9a652728ac1d673f68e01081a8039dca8c9f6 --- /dev/null +++ b/src/aspara/server/__init__.py @@ -0,0 +1,5 @@ +"""Server package - FastAPI application composition.""" + +from aspara.server._app import app + +__all__ = ["app"] diff --git a/src/aspara/server/_app.py b/src/aspara/server/_app.py new file mode 100644 index 0000000000000000000000000000000000000000..9de8903ccf6420bd4e407ea9199882ed66f49d56 --- /dev/null +++ b/src/aspara/server/_app.py @@ -0,0 +1,188 @@ +""" +Aspara main application +""" + +import importlib.util +import os +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse + +from aspara.config import is_dev_mode + +app = FastAPI( + title="Aspara", + description="A metrics tracker system for computer based experiments", + version="0.1.0", + docs_url=None, # Disable default /docs + redoc_url=None, # Disable default /redoc +) + +# No CORS middleware is configured intentionally. The dashboard static JS and API +# are served from the same origin, and the tracker API is consumed by the Python +# SDK rather than browsers, so CORS is unnecessary. A wildcard CORS policy would +# allow cross-origin sites to pass the X-Requested-With CSRF header check via +# preflight, defeating that protection. + + +def is_module_available(module_name: str) -> bool: + """Check if the specified module is available + + Args: + module_name: Module name to check + + Returns: + bool: True if module is available, False otherwise + """ + return importlib.util.find_spec(module_name) is not None + + +def should_mount_tracker() -> bool: + """Check if tracker should be mounted based on environment variable + + Returns: + bool: True if tracker should be mounted + """ + env_val = os.environ.get("ASPARA_SERVE_TRACKER") + if env_val is not None: + return env_val == "1" + return True # Backward compat: mount if available + + +def should_mount_dashboard() -> bool: + """Check if dashboard should be mounted based on environment variable + + Returns: + bool: True if dashboard should be mounted + """ + env_val = os.environ.get("ASPARA_SERVE_DASHBOARD") + if env_val is not None: + return env_val == "1" + return True # Backward compat: mount if available + + +if is_dev_mode(): + + @app.get("/docs", response_class=HTMLResponse) + async def docs_page(request: Request) -> HTMLResponse: + """Root endpoint - displays links to documentation + + Only available in development mode to avoid leaking endpoint + information in production deployments. + + Args: + request: Request object + + Returns: + HTMLResponse: HTML containing links to documentation + """ + tracker_available = is_module_available("aspara.tracker.main") + dashboard_available = is_module_available("aspara.dashboard.main") + + html_content = """ + + + + + + Aspara - Documentation + + + +

Aspara Documentation

+ +
+

Aspara Tracker API

+ """ + + if tracker_available: + html_content += """ +

Web API for recording and managing experiment metrics

+

Tracker API Documentation - API documentation based on OpenAPI specification

+

Tracker API ReDoc - Alternative API documentation

+ """ + else: + html_content += """ +

Tracker API is not installed

+

To install: uv pip install "aspara[tracker]"

+ """ + + html_content += """ +
+ +
+

Aspara Dashboard

+ """ + + if dashboard_available: + html_content += """ +

Web dashboard for visualizing experiment metrics

+

Open Dashboard - Metrics visualization interface

+

Dashboard API Documentation - Dashboard API documentation

+ """ + else: + html_content += """ +

Dashboard is not installed

+

To install: uv pip install "aspara[dashboard]"

+ """ + + html_content += """ +
+ + + """ + + return HTMLResponse(content=html_content) + + +if should_mount_tracker() and is_module_available("aspara.tracker.main"): + from aspara.tracker.main import app as tracker_app + + app.mount("/tracker", tracker_app) + +if should_mount_dashboard() and is_module_available("aspara.dashboard.main"): + import importlib + + from fastapi.staticfiles import StaticFiles + + dashboard_module = importlib.import_module("aspara.dashboard.main") + + BASE_DIR = Path(__file__).parent.parent / "dashboard" + app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") + + app.mount("", dashboard_module.app) diff --git a/src/aspara/storage/__init__.py b/src/aspara/storage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3f9c832c5a0e0e330a8f45f8a5e4226eb5b02d33 --- /dev/null +++ b/src/aspara/storage/__init__.py @@ -0,0 +1,24 @@ +""" +Aspara Storage module + +Provides MetricsStorage interface and implementations for persisting metrics data. +""" + +from .metadata import ProjectMetadataStorage, RunMetadataStorage +from .metrics import ( + JsonlMetricsStorage, # noqa: F401 + MetricsStorage, + PolarsMetricsStorage, # noqa: F401 + create_metrics_storage, + resolve_metrics_storage_backend, +) + +__all__ = [ + "MetricsStorage", + "create_metrics_storage", + "resolve_metrics_storage_backend", + "ProjectMetadataStorage", + "RunMetadataStorage", +] +# Note: JsonlMetricsStorage, PolarsMetricsStorage are imported but not in __all__ +# Internal code can still use explicit imports diff --git a/src/aspara/storage/metadata/__init__.py b/src/aspara/storage/metadata/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9ba1cecbe095af4012f08d64f6f90da31b211032 --- /dev/null +++ b/src/aspara/storage/metadata/__init__.py @@ -0,0 +1,5 @@ +from .models import MetadataUpdate, validate_metadata +from .project import ProjectMetadataStorage +from .run import RunMetadataStorage + +__all__ = ["MetadataUpdate", "ProjectMetadataStorage", "RunMetadataStorage", "validate_metadata"] diff --git a/src/aspara/storage/metadata/base.py b/src/aspara/storage/metadata/base.py new file mode 100644 index 0000000000000000000000000000000000000000..18685dd69ea77f41f3b6375d08bca68c1dc44f3d --- /dev/null +++ b/src/aspara/storage/metadata/base.py @@ -0,0 +1,87 @@ +"""Base class for file-based metadata storage.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from aspara.logger import logger +from aspara.utils import atomic_write_json + +from .models import validate_metadata + + +class BaseMetadataStorage: + """Base class for file-based metadata storage. + + Subclasses must set ``base_dir``, ``_metadata_path`` and ``_metadata`` + before calling :meth:`_load`, and implement :meth:`_get_metadata_path`. + """ + + base_dir: Path + _metadata_path: Path + _metadata: dict[str, Any] + + def _get_metadata_path(self) -> Path: + """Return the path to the metadata file. + + Raises: + NotImplementedError: If the subclass does not override this. + """ + raise NotImplementedError + + def _merge_loaded(self, loaded: dict[str, Any]) -> dict[str, Any]: + """Merge loaded JSON with defaults. + + Override in subclasses that need to normalise or fill in missing + fields. The default implementation returns the loaded dict as-is. + """ + return loaded + + def _load(self) -> None: + """Load existing metadata from file if it exists. + + Uses try/except instead of an exists() check to avoid TOCTOU races. + On ``FileNotFoundError`` the default values are kept silently; on + corruption or read errors a warning is logged and defaults are kept. + """ + try: + with open(self._metadata_path, encoding="utf-8") as f: + loaded = json.load(f) + self._metadata = self._merge_loaded(loaded) + except FileNotFoundError: + # File doesn't exist yet, keep default values + pass + except (json.JSONDecodeError, OSError) as e: + # File is corrupted or unreadable, keep default values + logger.warning(f"Failed to load metadata from {self._metadata_path}: {type(e).__name__}: {e}") + + def _save(self) -> None: + """Save metadata to file. + + Raises: + ValueError: If the metadata file cannot be written. + """ + self._metadata_path.parent.mkdir(parents=True, exist_ok=True) + + try: + atomic_write_json(self._metadata_path, self._metadata) + except OSError as e: + raise ValueError(f"Failed to write metadata file: {e}") from e + + def _validate_metadata_values(self, metadata: dict[str, Any]) -> None: + """Validate notes/tags against resource limits. + + Raises: + ValueError: If validation fails. + """ + validate_metadata(metadata) + + def get_metadata(self) -> dict[str, Any]: + """Return a shallow copy of all metadata.""" + return dict(self._metadata) + + def close(self) -> None: + """Close storage (no-op for file-based storage).""" + pass diff --git a/src/aspara/storage/metadata/models.py b/src/aspara/storage/metadata/models.py new file mode 100644 index 0000000000000000000000000000000000000000..07dd10b1b450c6e6474aa48654053eec268a5862 --- /dev/null +++ b/src/aspara/storage/metadata/models.py @@ -0,0 +1,78 @@ +""" +Pydantic models for metadata validation. + +This module provides Pydantic models for validating metadata updates +with resource limits integration. +""" + +from typing import Any + +from pydantic import BaseModel, field_validator + +from aspara.config import get_resource_limits + +__all__ = ["MetadataUpdate", "validate_metadata"] + + +class MetadataUpdate(BaseModel): + """Model for validating metadata updates. + + Validates notes and tags against configured resource limits. + All fields are optional to support partial updates. + """ + + notes: str | None = None + tags: list[str] | None = None + + @field_validator("notes", mode="before") + @classmethod + def validate_notes_type(cls, v: Any) -> str | None: + """Validate notes type and length against resource limits.""" + if v is None: + return v + if not isinstance(v, str): + raise ValueError("notes must be a string") + limits = get_resource_limits() + if len(v) > limits.max_notes_length: + raise ValueError(f"notes exceeds maximum length: {len(v)} characters (max: {limits.max_notes_length})") + return v + + @field_validator("tags", mode="before") + @classmethod + def validate_tags_type_and_count(cls, v: Any) -> list[str] | None: + """Validate tags type and count against resource limits.""" + if v is None: + return v + if not isinstance(v, list): + raise ValueError("tags must be a list") + limits = get_resource_limits() + if len(v) > limits.max_tags_count: + raise ValueError(f"Too many tags: {len(v)} (max: {limits.max_tags_count})") + # Validate each tag is a string + for tag in v: + if not isinstance(tag, str): + raise ValueError("All tags must be strings") + return v + + +def validate_metadata(metadata: dict[str, Any]) -> None: + """Validate metadata dictionary using Pydantic model. + + This function provides a simple interface for validating metadata + dictionaries, converting Pydantic ValidationError to ValueError + for backwards compatibility. + + Args: + metadata: Metadata dictionary to validate. May contain 'notes' and/or 'tags'. + + Raises: + ValueError: If validation fails. + """ + from pydantic import ValidationError + + try: + MetadataUpdate.model_validate(metadata) + except ValidationError as e: + # Extract the first error message for backwards compatibility + error = e.errors()[0] + raise ValueError(error["msg"]) from None diff --git a/src/aspara/storage/metadata/project.py b/src/aspara/storage/metadata/project.py new file mode 100644 index 0000000000000000000000000000000000000000..b48d9cf4a5b4bb1c35d02083cf48d2374c1c7037 --- /dev/null +++ b/src/aspara/storage/metadata/project.py @@ -0,0 +1,145 @@ +""" +ProjectMetadataStorage - Project-level metadata storage. + +This module provides storage for project metadata (notes, tags, timestamps) +used by the catalog and dashboard layers. +""" + +import copy +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, ClassVar + +from aspara.logger import logger +from aspara.utils.validators import validate_name, validate_safe_path + +from .base import BaseMetadataStorage + + +class ProjectMetadataStorage(BaseMetadataStorage): + """Project-level metadata storage. + + Stores project metadata in {project_name}/metadata.json files. + This includes notes, tags, created_at, and updated_at timestamps. + """ + + _DEFAULT_METADATA: ClassVar[dict[str, Any]] = { + "notes": "", + "tags": [], + "created_at": None, + "updated_at": None, + } + + @classmethod + def default_metadata(cls) -> dict[str, Any]: + """Return a fresh copy of the default metadata values.""" + return copy.deepcopy(cls._DEFAULT_METADATA) + + @classmethod + def _normalize_loaded(cls, loaded: dict[str, Any]) -> dict[str, Any]: + """Normalise loaded metadata, filling missing fields with defaults.""" + return { + "notes": loaded.get("notes", ""), + "tags": loaded.get("tags", []), + "created_at": loaded.get("created_at"), + "updated_at": loaded.get("updated_at"), + } + + @classmethod + def load_metadata_file(cls, path: Path) -> dict[str, Any]: + """Load and normalise metadata from a JSON file. + + Returns the default metadata if the file is missing or unreadable. + """ + try: + with open(path, encoding="utf-8") as f: + loaded = json.load(f) + except FileNotFoundError: + return cls.default_metadata() + except (json.JSONDecodeError, OSError) as e: + logger.warning(f"Failed to load metadata from {path}: {type(e).__name__}: {e}") + return cls.default_metadata() + return cls._normalize_loaded(loaded) + + def __init__(self, base_dir: str | Path, project_name: str) -> None: + """Initialize project metadata storage. + + Args: + base_dir: Base directory for data storage + project_name: Project name + + Raises: + ValueError: If project name is invalid or path is unsafe + """ + validate_name(project_name, "project name") + + self.base_dir = Path(base_dir) + self.project_name = project_name + + self._metadata_path = self._get_metadata_path() + validate_safe_path(self._metadata_path, self.base_dir) + + self._metadata: dict[str, Any] = self.default_metadata() + self._load() + + def _get_metadata_path(self) -> Path: + """Get the path to this project's metadata file. + + Returns: + Path to the metadata.json file + """ + return self.base_dir / self.project_name / "metadata.json" + + def _merge_loaded(self, loaded: dict[str, Any]) -> dict[str, Any]: + """Normalise loaded metadata, filling missing fields with defaults.""" + return self._normalize_loaded(loaded) + + def update_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]: + """Update metadata. + + The metadata dict may contain partial fields (notes, tags). + Timestamps are managed automatically. + + Args: + metadata: Metadata dictionary with fields to update + + Returns: + Updated metadata dictionary + + Raises: + ValueError: If validation fails + """ + self._validate_metadata_values(metadata) + + now = datetime.now(timezone.utc).isoformat() + + if self._metadata["created_at"] is None: + self._metadata["created_at"] = now + + self._metadata["updated_at"] = now + + if "notes" in metadata: + self._metadata["notes"] = metadata["notes"] + + if "tags" in metadata: + self._metadata["tags"] = metadata["tags"] + + self._save() + return dict(self._metadata) + + def delete_metadata(self) -> bool: + """Delete metadata file. + + Returns: + True if deleted, False if it didn't exist + """ + try: + self._metadata_path.unlink() + except FileNotFoundError: + return False + except OSError as e: + logger.warning(f"Failed to delete project metadata file {self._metadata_path}: {e}") + return False + self._metadata = self.default_metadata() + return True diff --git a/src/aspara/storage/metadata/run.py b/src/aspara/storage/metadata/run.py new file mode 100644 index 0000000000000000000000000000000000000000..7f69627f7369e2a7272caf011de69ff90296fd6c --- /dev/null +++ b/src/aspara/storage/metadata/run.py @@ -0,0 +1,276 @@ +""" +RunMetadataStorage - Run-level metadata storage. + +This module provides storage for run metadata (params, config, tags, notes, etc.) +used for experiment tracking. +""" + +from pathlib import Path +from typing import Any + +from aspara.models import RunStatus +from aspara.utils.validators import validate_name, validate_safe_path + +from .base import BaseMetadataStorage + + +class RunMetadataStorage(BaseMetadataStorage): + """Run-level metadata storage. + + Stores run metadata in {run_name}.meta.json files. + This includes params, config, tags, notes, artifacts, summary, and other run-specific data. + """ + + def __init__(self, base_dir: str | Path, project_name: str, run_name: str) -> None: + """Initialize run metadata storage. + + Args: + base_dir: Base directory for data storage + project_name: Project name + run_name: Run name + + Raises: + ValueError: If project/run name is invalid or path is unsafe + """ + validate_name(project_name, "project name") + validate_name(run_name, "run name") + + self.base_dir = Path(base_dir) + self.project_name = project_name + self.run_name = run_name + + self._metadata_path = self._get_metadata_path() + validate_safe_path(self._metadata_path, self.base_dir) + + self._metadata: dict[str, Any] = { + "run_id": None, + "tags": [], + "notes": "", + "params": {}, + "config": {}, + "artifacts": [], + "summary": {}, + "is_finished": False, + "exit_code": None, + "status": RunStatus.WIP.value, + "start_time": None, + "finish_time": None, + } + self._load() + + def _get_metadata_path(self) -> Path: + """Get the path to this run's metadata file. + + Returns: + Path to the metadata JSON file + """ + return self.base_dir / self.project_name / f"{self.run_name}.meta.json" + + def set_init( + self, + run_id: str, + tags: list[str] | None = None, + notes: str | None = None, + timestamp: int | None = None, + ) -> None: + """Set initial run metadata. + + Args: + run_id: Unique run identifier + tags: List of tags + notes: Run notes/description + timestamp: UNIX time in milliseconds (int) + + Raises: + ValueError: If tags or notes validation fails + """ + values_to_validate: dict[str, Any] = {} + if tags is not None: + values_to_validate["tags"] = tags + if notes is not None: + values_to_validate["notes"] = notes + self._validate_metadata_values(values_to_validate) + + self._metadata["run_id"] = run_id + if tags is not None: + self._metadata["tags"] = tags + if notes is not None: + self._metadata["notes"] = notes + if timestamp is not None and self._metadata["start_time"] is None: + self._metadata["start_time"] = timestamp + self._save() + + def update_config(self, config: dict[str, Any]) -> None: + """Update config parameters. + + Args: + config: Configuration dictionary to merge + """ + self._metadata["config"].update(config) + self._save() + + def update_params(self, params: dict[str, Any]) -> None: + """Update parameters. + + Args: + params: Parameters dictionary to merge + """ + self._metadata["params"].update(params) + self._save() + + def add_artifact(self, artifact_data: dict[str, Any]) -> None: + """Add artifact metadata. + + Args: + artifact_data: Artifact metadata dictionary + """ + self._metadata["artifacts"].append(artifact_data) + self._save() + + def update_summary(self, summary: dict[str, Any]) -> None: + """Update summary data. + + Args: + summary: Summary dictionary to merge + """ + self._metadata["summary"].update(summary) + self._save() + + def set_finish(self, exit_code: int, timestamp: int) -> None: + """Mark run as finished. + + Args: + exit_code: Exit code (0 = success) + timestamp: UNIX time in milliseconds (int) + """ + self._metadata["is_finished"] = True + self._metadata["exit_code"] = exit_code + self._metadata["finish_time"] = timestamp + + status = RunStatus.from_is_finished_and_exit_code(True, exit_code) + self._metadata["status"] = status.value + + self._save() + + def reset_finish(self) -> None: + """Reset finish state so the run can receive new metrics. + + Used when resuming an existing run: clears ``is_finished``, + ``exit_code``, ``finish_time`` and resets ``status`` to WIP. + """ + self._metadata["is_finished"] = False + self._metadata["exit_code"] = None + self._metadata["finish_time"] = None + self._metadata["status"] = RunStatus.WIP.value + self._save() + + def set_tags(self, tags: list[str]) -> None: + """Set tags (replaces existing tags). + + Args: + tags: List of tags + + Raises: + ValueError: If tags validation fails + """ + self._validate_metadata_values({"tags": tags}) + self._metadata["tags"] = tags + self._save() + + def update_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]: + """Update metadata fields (notes, tags). + + Args: + metadata: Dictionary with fields to update + + Returns: + Updated complete metadata dictionary + + Raises: + ValueError: If validation fails + """ + self._validate_metadata_values(metadata) + + if "notes" in metadata: + self._metadata["notes"] = metadata["notes"] + if "tags" in metadata: + self._metadata["tags"] = metadata["tags"] + + self._save() + return dict(self._metadata) + + def delete_metadata(self) -> bool: + """Delete metadata file. + + Returns: + True if file was deleted, False if it didn't exist + """ + try: + self._metadata_path.unlink() + except FileNotFoundError: + return False + self._metadata = { + "run_id": None, + "tags": [], + "notes": "", + "params": {}, + "config": {}, + "artifacts": [], + "summary": {}, + "is_finished": False, + "exit_code": None, + "status": RunStatus.WIP.value, + "start_time": None, + "finish_time": None, + } + return True + + def get_params(self) -> dict[str, Any]: + """Get params and config merged. + + Returns: + Dictionary with all params and config + """ + result: dict[str, Any] = {} + result.update(self._metadata.get("params", {})) + result.update(self._metadata.get("config", {})) + return result + + def get_artifacts(self) -> list[dict[str, Any]]: + """Get artifact metadata list. + + Returns: + List of artifact metadata dictionaries + """ + return list(self._metadata.get("artifacts", [])) + + def get_tags(self) -> list[str]: + """Get tags. + + Returns: + List of tags + """ + return list(self._metadata.get("tags", [])) + + def set_status(self, status: RunStatus) -> None: + """Set run status. + + Args: + status: New run status + """ + self._metadata["status"] = status.value + + is_finished, exit_code = status.to_is_finished_and_exit_code() + self._metadata["is_finished"] = is_finished + self._metadata["exit_code"] = exit_code + + self._save() + + def get_status(self) -> RunStatus: + """Get run status. + + Returns: + Current run status + """ + status_value = self._metadata.get("status", RunStatus.WIP.value) + return RunStatus(status_value) diff --git a/src/aspara/storage/metrics/__init__.py b/src/aspara/storage/metrics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fa6bd717995d9c749a1d8ca5831e75cc011b383c --- /dev/null +++ b/src/aspara/storage/metrics/__init__.py @@ -0,0 +1,82 @@ +from aspara.config import get_storage_backend + +from .base import MetricsStorage +from .jsonl import JsonlMetricsStorage +from .polars import PolarsMetricsStorage + +DEFAULT_METRICS_STORAGE_BACKEND = "jsonl" +_VALID_METRICS_STORAGE_BACKENDS = {"jsonl", "polars"} + + +def resolve_metrics_storage_backend(storage_backend: str | None = None) -> str: + """Resolve the metrics storage backend name. + + Resolution order: + 1. ASPARA_STORAGE_BACKEND environment variable (if set and valid) + 2. storage_backend argument (if set and valid) + 3. DEFAULT_METRICS_STORAGE_BACKEND ("jsonl") when neither is set + + Raises: + ValueError: If ASPARA_STORAGE_BACKEND or storage_backend is set but invalid. + """ + + env_backend = get_storage_backend() + + if env_backend is not None: + if env_backend in _VALID_METRICS_STORAGE_BACKENDS: + return env_backend + msg = f"Invalid ASPARA_STORAGE_BACKEND value: {env_backend!r}. Valid values are: {sorted(_VALID_METRICS_STORAGE_BACKENDS)}" + raise ValueError(msg) + + if storage_backend is not None: + if storage_backend in _VALID_METRICS_STORAGE_BACKENDS: + return storage_backend + msg = f"Invalid storage_backend value: {storage_backend!r}. Valid values are: {sorted(_VALID_METRICS_STORAGE_BACKENDS)}" + raise ValueError(msg) + + return DEFAULT_METRICS_STORAGE_BACKEND + + +def create_metrics_storage( + backend: str | None = None, + *, + base_dir: str, + project_name: str, + run_name: str, +) -> MetricsStorage: + """Create a metrics storage instance. + + This is the recommended way to create storage instances. + The backend is resolved via resolve_metrics_storage_backend(). + + Args: + backend: Storage backend type ('jsonl' or 'polars'). + If None, uses ASPARA_STORAGE_BACKEND env var or defaults to 'jsonl'. + base_dir: Base directory for data storage. + project_name: Name of the project. + run_name: Name of the run. + + Returns: + MetricsStorage instance (JsonlMetricsStorage or PolarsMetricsStorage). + """ + resolved = resolve_metrics_storage_backend(backend) + if resolved == "polars": + return PolarsMetricsStorage( + base_dir=base_dir, + project_name=project_name, + run_name=run_name, + ) + return JsonlMetricsStorage( + base_dir=base_dir, + project_name=project_name, + run_name=run_name, + ) + + +__all__ = [ + "MetricsStorage", + "JsonlMetricsStorage", + "PolarsMetricsStorage", + "create_metrics_storage", + "resolve_metrics_storage_backend", +] diff --git a/src/aspara/storage/metrics/base.py b/src/aspara/storage/metrics/base.py new file mode 100644 index 0000000000000000000000000000000000000000..579dd1b20bbf5a15d91f84fb0e2de937b4a3e172 --- /dev/null +++ b/src/aspara/storage/metrics/base.py @@ -0,0 +1,70 @@ +""" +MetricsStorage - Abstract base class for metrics persistence. + +This module defines the interface for storing and loading metrics data. +Unlike the old StorageBackend, this class focuses solely on metrics data +and does not handle project/run discovery (that's Catalog's responsibility). +""" + +from abc import ABC, abstractmethod +from typing import Any + +import polars as pl + + +class MetricsStorage(ABC): + """Abstract base class for metrics storage. + + This class defines the interface for persisting metrics data. + Each instance is bound to a specific project/run combination. + + Note: Project and run discovery is handled by ProjectCatalog and RunCatalog, + not by this class. + """ + + @abstractmethod + def save(self, metrics_data: dict[str, Any]) -> str: # pragma: no cover - interface only + """Save metrics data for this run. + + Args: + metrics_data: Metrics data to save (should include timestamp, step, metrics dict) + + Returns: + str: Request ID if available, empty string otherwise + """ + raise NotImplementedError + + @abstractmethod + def load( + self, + metric_names: list[str] | None = None, + ) -> pl.DataFrame: # pragma: no cover - interface only + """Load metrics data for this run. + + Args: + metric_names: Optional list of metric names to filter by. + If None, returns all metrics. + + Returns: + List of metrics data dictionaries, sorted by timestamp. + Each dict contains: timestamp, step, metrics (dict of name->value) + + Raises: + RunNotFoundError: If the run does not exist + """ + raise NotImplementedError + + def finish(self) -> None: # noqa: B027 + """Run completion processing (e.g., final archiving, statistics). + + Called when aspara.finish() is invoked to perform any final processing + before the run is considered complete. + + Default implementation does nothing. Override if needed. + """ + + def close(self) -> None: # noqa: B027 + """Close the storage backend and release any resources. + + Default implementation does nothing. Override if cleanup is needed. + """ diff --git a/src/aspara/storage/metrics/jsonl.py b/src/aspara/storage/metrics/jsonl.py new file mode 100644 index 0000000000000000000000000000000000000000..573d123835950f176ceb040b091bfaed2002675c --- /dev/null +++ b/src/aspara/storage/metrics/jsonl.py @@ -0,0 +1,164 @@ +""" +JsonlMetricsStorage - JSONL file-based metrics storage. + +This module provides a simple, portable storage backend using JSONL files. +Timestamps are stored as UNIX milliseconds (UTC) for efficient parsing with Polars. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import polars as pl + +from aspara.config import get_resource_limits +from aspara.exceptions import RunNotFoundError +from aspara.utils import datasync, secure_open_append + +from .base import MetricsStorage + + +class JsonlMetricsStorage(MetricsStorage): + """JSONL file-based metrics storage. + + Stores metrics data in JSONL (JSON Lines) format, with one JSON object per line. + This format is simple, human-readable, and easy to append to. + """ + + def __init__(self, base_dir: str | Path, project_name: str, run_name: str) -> None: + """Initialize JSONL storage. + + Args: + base_dir: Base directory for data storage + project_name: Project name + run_name: Run name + """ + self.base_dir = Path(base_dir) + self.base_dir.mkdir(exist_ok=True, parents=True) + self.project_name = project_name + self.run_name = run_name + + def _get_run_file(self) -> Path: + """Get the path to this run's JSONL file. + + Returns: + Path to the JSONL file + """ + project_dir = self.base_dir / self.project_name + project_dir.mkdir(exist_ok=True, parents=True) + return project_dir / f"{self.run_name}.jsonl" + + def save(self, metrics_data: dict[str, Any]) -> str: + """Save metrics data to JSONL file. + + Args: + metrics_data: Metrics data to save + + Returns: + str: Empty string + + Raises: + ValueError: If file size exceeds limit + """ + run_file = self._get_run_file() + limits = get_resource_limits() + + # Serialize data first to know the size before opening the file + new_data = json.dumps(metrics_data) + "\n" + new_data_bytes = new_data.encode("utf-8") + + # Open file securely with proper permissions (0o600) + with secure_open_append(run_file) as f: + # Get current file size after opening (avoids race condition) + f.seek(0, 2) # Seek to end + current_size = f.tell() + + # Check if adding new data would exceed limit + if current_size + len(new_data_bytes) > limits.max_file_size: + raise ValueError(f"File size limit would be exceeded: {current_size} + {len(new_data_bytes)} bytes (max: {limits.max_file_size} bytes)") + + f.write(new_data) + f.flush() + datasync(f.fileno()) + + return "" + + def load( + self, + metric_names: list[str] | None = None, + ) -> pl.DataFrame: + """Load metrics data from JSONL file in wide format. + + Uses Polars lazy API for optimized query execution. + + Args: + metric_names: Optional list of metric names to filter by + + Returns: + Polars DataFrame in wide format with columns: + - timestamp: Datetime + - step: Int64 + - _: Float64 for each metric (underscore-prefixed) + + Raises: + RunNotFoundError: If the run file does not exist + """ + run_file = self._get_run_file() + + if not run_file.exists(): + raise RunNotFoundError(f"Run '{self.run_name}' not found in project '{self.project_name}'") + + # Use lazy scan for optimized query planning + lf = pl.scan_ndjson(run_file) + + # Get schema to check columns (need to collect for schema inspection) + schema = lf.collect_schema() + + if len(schema) == 0: + return pl.DataFrame( + schema={ + "timestamp": pl.Datetime, + "step": pl.Int64, + } + ) + + # Build transformation chain using lazy API + if "metrics" in schema: + # Keep only the core record fields; drop any envelope fields + # (e.g. type, run, project) so they aren't mistaken for metrics. + # Using an allowlist avoids fragility as envelope fields evolve. + core_cols = [c for c in ("timestamp", "step", "metrics") if c in schema.names()] + lf = lf.select(core_cols) + + # Unnest the metrics struct to get individual metric columns + lf = lf.unnest("metrics") + + # Re-fetch schema after unnest + schema = lf.collect_schema() + + # Rename metric columns to add underscore prefix + # (all remaining columns except timestamp and step are metrics) + metric_cols = [col for col in schema.names() if col not in ("timestamp", "step")] + rename_map = {col: f"_{col}" for col in metric_cols} + lf = lf.rename(rename_map) + + # Filter by metric names if specified + if metric_names is not None: + keep_cols = ["timestamp", "step"] + [f"_{name}" for name in metric_names if f"_{name}" in lf.collect_schema().names()] + lf = lf.select(keep_cols) + + # Timestamp conversion - UNIX ms to Datetime + if "timestamp" in lf.collect_schema(): + lf = lf.with_columns(pl.col("timestamp").cast(pl.Datetime("ms"))) + + # Sort by timestamp and step, then collect + return lf.sort(["timestamp", "step"]).collect() + + def close(self) -> None: + """Close storage backend. + + This backend doesn't hold long-lived connections, so this is a no-op. + """ + pass diff --git a/src/aspara/storage/metrics/polars.py b/src/aspara/storage/metrics/polars.py new file mode 100644 index 0000000000000000000000000000000000000000..52b415263f90ff0739bd3780daca10c9e54c0307 --- /dev/null +++ b/src/aspara/storage/metrics/polars.py @@ -0,0 +1,497 @@ +""" +PolarsMetricsStorage - Polars/PyArrow-based metrics storage with WAL. + +This module provides high-performance storage using Polars for data manipulation +and PyArrow for Parquet I/O, with a Write-Ahead Log (WAL) pattern for lock-free concurrent access. +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import polars as pl + +from aspara.exceptions import RunNotFoundError +from aspara.logger import logger +from aspara.utils import atomic_write_text, datasync, secure_open_append + +from .base import MetricsStorage + + +class PolarsMetricsStorage(MetricsStorage): + """Polars/PyArrow-based metrics storage with WAL for concurrent access. + + Write path: + 1. All writes go to WAL (JSONL) - fast, no locks + 2. When WAL exceeds threshold, archive to Parquet via Polars and clear WAL + + Read path: + 1. Read from Parquet archives (archived data) - no locks + 2. Read from WAL (recent data) - no locks + 3. Merge and return sorted results + + This design allows: + - Writer and Reader to coexist without lock contention + - Fast writes (JSONL append is very fast) + - Efficient queries on large datasets (Parquet via Polars) + - Direct Parquet manipulation without SQL overhead + """ + + def __init__( + self, + base_dir: str | Path, + project_name: str, + run_name: str, + archive_threshold_bytes: int = 1 * 1024 * 1024, + ) -> None: + """Initialize Parquet storage. + + Args: + base_dir: Base directory for data storage + project_name: Project name + run_name: Run name + archive_threshold_bytes: WAL size threshold to trigger archiving (default: 1MB) + """ + self.base_dir = Path(base_dir) + self.base_dir.mkdir(exist_ok=True, parents=True) + self.project_name = project_name + self.run_name = run_name + self._archive_threshold = archive_threshold_bytes + + def _get_wal_path(self) -> Path: + """Get WAL file path for this run.""" + project_dir = self.base_dir / self.project_name + project_dir.mkdir(exist_ok=True, parents=True) + return project_dir / f"{self.run_name}.wal.jsonl" + + def _get_archive_path(self) -> Path: + """Get archive directory path for this run.""" + return self.base_dir / self.project_name / f"{self.run_name}_archive" + + def _write_to_wal(self, wal_path: Path, data: dict[str, Any]) -> None: + """Write data to WAL with fdatasync for durability. + + Uses secure_open_append to ensure file is created with 0o600 permissions. + """ + with secure_open_append(wal_path) as f: + f.write(json.dumps(data) + "\n") + f.flush() + datasync(f.fileno()) + + def _read_wal(self, wal_path: Path) -> list[dict[str, Any]]: + """Read all records from WAL.""" + records = [] + try: + with open(wal_path) as f: + for line in f: + line = line.strip() + if line: + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + continue + except FileNotFoundError: + pass + return records + + def _parse_timestamp(self, ts: int | str) -> datetime: + """Parse timestamp from UNIX milliseconds (int) or ISO 8601 (str). + + Args: + ts: Timestamp as UNIX milliseconds (int) or ISO 8601 string + + Returns: + datetime: Parsed datetime object + """ + if isinstance(ts, int): + return datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc) + return datetime.fromisoformat(ts) + + def _create_long_dataframe(self, rows: list[dict[str, Any]]) -> pl.DataFrame: + """Create a long-format DataFrame from row data. + + Args: + rows: List of dicts with keys: timestamp, step, metric_name, metric_value + + Returns: + Polars DataFrame with schema: + - timestamp: Datetime + - step: Int64 + - metric_name: Utf8 + - metric_value: Float64 + """ + return pl.DataFrame( + rows, + schema={ + "timestamp": pl.Datetime, + "step": pl.Int64, + "metric_name": pl.Utf8, + "metric_value": pl.Float64, + }, + ) + + def _pivot_to_wide(self, df_long: pl.DataFrame) -> pl.DataFrame: + """Pivot a long-format DataFrame to wide format. + + Args: + df_long: DataFrame with columns: timestamp, step, metric_name, metric_value + + Returns: + DataFrame with columns: timestamp, step, and one column per metric + """ + return df_long.pivot( + values="metric_value", + index=["timestamp", "step"], + on="metric_name", + aggregate_function="first", + ).sort(["timestamp", "step"]) + + def _expand_metrics_to_rows( + self, + records: list[dict[str, Any]], + metric_names: list[str] | None = None, + prefix_underscore: bool = False, + ) -> list[dict[str, Any]]: + """Expand WAL records into long-format rows. + + Args: + records: List of WAL records with keys: timestamp, step, metrics + metric_names: Optional list of metric names to filter by + prefix_underscore: If True, prefix metric names with underscore + + Returns: + List of row dicts with keys: timestamp, step, metric_name, metric_value + """ + rows: list[dict[str, Any]] = [] + for data in records: + timestamp = self._parse_timestamp(data["timestamp"]) + step = data["step"] + metrics_dict = data.get("metrics", {}) + + # Filter by metric names if specified + if metric_names is not None: + metrics_dict = {k: v for k, v in metrics_dict.items() if k in metric_names} + + for metric_name, metric_value in metrics_dict.items(): + # Skip non-numeric values (metrics should always be numeric) + try: + numeric_value = float(metric_value) + name = f"_{metric_name}" if prefix_underscore else metric_name + rows.append({ + "timestamp": timestamp, + "step": step, + "metric_name": name, + "metric_value": numeric_value, + }) + except (ValueError, TypeError): + # Skip non-numeric values + pass + + return rows + + def _read_existing_parquet_data(self, archive_path: Path) -> pl.DataFrame | None: + """Read existing Parquet data from latest date partition. + + Args: + archive_path: Path to the archive directory + + Returns: + DataFrame with existing data, or None if no data exists + """ + date_dirs = [d for d in archive_path.iterdir() if d.is_dir() and d.name.startswith("date=")] + + if not date_dirs: + return None + + # Get the latest date + latest_date_str = max(d.name.split("=")[1] for d in date_dirs) + latest_date_dir = archive_path / f"date={latest_date_str}" + + # Read existing Parquet files from latest date + parquet_files = list(latest_date_dir.glob("*.parquet")) + if not parquet_files: + return None + + return pl.read_parquet(latest_date_dir / "*.parquet") + + def _add_date_partition(self, df: pl.DataFrame) -> pl.DataFrame: + """Add date column for partitioning based on timestamp. + + Args: + df: DataFrame with timestamp column + + Returns: + DataFrame with added date column + """ + return df.with_columns(pl.col("timestamp").cast(pl.Date).alias("date")) + + def _write_partitioned_parquet(self, df: pl.DataFrame, archive_path: Path) -> None: + """Write DataFrame to Parquet with date-based partitioning. + + Args: + df: DataFrame with date column + archive_path: Path to the archive directory + """ + df.write_parquet( + archive_path, + compression="zstd", + compression_level=1, + partition_by="date", + ) + + def _clear_wal(self, wal_path: Path) -> None: + """Clear WAL atomically. + + Writes an empty file to a temp path, fsyncs it, then atomically + renames it over the WAL. This avoids the data-loss window where + a plain ``open(w, "w")`` truncates the file before the new (empty) + content is durable. + + Args: + wal_path: Path to the WAL file + """ + atomic_write_text(wal_path, lambda f: None, suffix=".wal.tmp") + + def _load_from_parquet( + self, + archive_path: Path, + metric_names: list[str] | None = None, + ) -> pl.DataFrame | None: + """Load metrics from Parquet archives in wide format. + + Args: + archive_path: Path to the archive directory + metric_names: Optional list of metric names to filter by + + Returns: + DataFrame in wide format, or None if no data exists + """ + if not archive_path.exists(): + return None + + try: + # Read all Parquet files (columns: timestamp, step, metric_name, metric_value, date) + df_long = pl.read_parquet(archive_path / "**" / "*.parquet") + + # Filter by metric names if specified + if metric_names is not None: + df_long = df_long.filter(pl.col("metric_name").is_in(metric_names)) + + # Add underscore prefix to metric names + df_long = df_long.with_columns(pl.concat_str([pl.lit("_"), pl.col("metric_name")]).alias("metric_name")) + + return self._pivot_to_wide(df_long) + except Exception: + # If no parquet files exist yet, that's okay + return None + + def _load_from_wal( + self, + wal_path: Path, + metric_names: list[str] | None = None, + ) -> pl.DataFrame | None: + """Load metrics from WAL in wide format. + + Args: + wal_path: Path to the WAL file + metric_names: Optional list of metric names to filter by + + Returns: + DataFrame in wide format, or None if no data exists + """ + wal_records = self._read_wal(wal_path) + if not wal_records: + return None + + rows = self._expand_metrics_to_rows(wal_records, metric_names=metric_names, prefix_underscore=True) + if not rows: + return None + + df_long = self._create_long_dataframe(rows) + return self._pivot_to_wide(df_long) + + def _combine_dataframes(self, dfs: list[pl.DataFrame]) -> pl.DataFrame: + """Combine multiple DataFrames into one sorted DataFrame. + + Args: + dfs: List of DataFrames to combine + + Returns: + Combined and sorted DataFrame + """ + if not dfs: + # Return empty DataFrame with correct schema + return pl.DataFrame( + schema={ + "timestamp": pl.Datetime, + "step": pl.Int64, + } + ) + + if len(dfs) == 1: + return dfs[0] + + # Concatenate and handle overlapping columns + return pl.concat(dfs, how="diagonal").sort(["timestamp", "step"]) + + def _get_archive_marker_path(self) -> Path: + """Get the marker file path used for two-phase archive commit.""" + return self._get_archive_path().parent / f"{self.run_name}.archive.marker" + + def _try_archive(self) -> bool: + """Try to archive WAL to Parquet via Polars. + + Uses a two-phase commit with a marker file to avoid data loss or + duplication if the process crashes mid-archive: + + 1. Write Parquet (combined existing + WAL data) + 2. Write the marker file (Parquet is committed, WAL can be cleared) + 3. Clear WAL atomically + 4. Remove the marker + + If a marker is found on startup, the Parquet write had completed but + the WAL was not yet cleared. Clear the WAL to prevent duplication. + + Returns: + bool: True if archive succeeded + """ + wal_path = self._get_wal_path() + marker_path = self._get_archive_marker_path() + + # Recovery: if a marker from a previous (crashed) archive exists, + # the Parquet write had completed but the WAL was not yet cleared. + # Clear the WAL now to prevent duplicate data on the next archive. + if marker_path.exists(): + logger.info(f"Found stale archive marker for {self.project_name}/{self.run_name}; clearing WAL to prevent data duplication") + self._clear_wal(wal_path) + marker_path.unlink(missing_ok=True) + + try: + records = self._read_wal(wal_path) + if not records: + return True + + archive_path = self._get_archive_path() + archive_path.mkdir(exist_ok=True, parents=True) + + # Load existing data from latest date partition + existing_df = self._read_existing_parquet_data(archive_path) + + # Convert WAL records to DataFrame + rows = self._expand_metrics_to_rows(records) + new_df = self._create_long_dataframe(rows) + new_df = self._add_date_partition(new_df) + + # Combine existing and new data + combined_df = pl.concat([existing_df, new_df]) if existing_df is not None else new_df + + # Phase 1: write Parquet + self._write_partitioned_parquet(combined_df, archive_path) + + # Phase 2: write marker (Parquet is committed, WAL safe to clear) + marker_path.write_text("archived", encoding="utf-8") + marker_fd = os.open(str(marker_path), os.O_RDONLY) + try: + os.fsync(marker_fd) + finally: + os.close(marker_fd) + + # Phase 3: clear WAL atomically + self._clear_wal(wal_path) + + # Phase 4: remove marker (archive complete) + marker_path.unlink(missing_ok=True) + return True + + except Exception as e: # pragma: no cover - best-effort logging + logger.warning(f"Archive failed for {self.project_name}/{self.run_name}: {e}") + return False + + def save(self, metrics_data: dict[str, Any]) -> str: + """Save metrics to WAL. + + Args: + metrics_data: Metrics data to save + + Returns: + str: Empty string + """ + wal_path = self._get_wal_path() + + # Check if archiving is needed BEFORE writing + try: + needs_archive = wal_path.stat().st_size >= self._archive_threshold + except FileNotFoundError: + needs_archive = False + if needs_archive: + self._try_archive() + + try: + self._write_to_wal(wal_path, metrics_data) + except OSError as e: # pragma: no cover - error path + raise RuntimeError(f"Failed to write to WAL: {e}") from e + + return "" + + def load( + self, + metric_names: list[str] | None = None, + ) -> pl.DataFrame: + """Load metrics from Parquet archives + WAL in wide format. + + Args: + metric_names: Optional list of metric names to filter by + + Returns: + Polars DataFrame in wide format with columns: + - timestamp: Datetime + - step: Int64 + - _: Float64 for each metric (underscore-prefixed) + + Raises: + RunNotFoundError: If the run does not exist + """ + wal_path = self._get_wal_path() + archive_path = self._get_archive_path() + + if not wal_path.exists() and not archive_path.exists(): + raise RunNotFoundError(f"Run '{self.run_name}' not found in project '{self.project_name}'") + + dfs_to_concat: list[pl.DataFrame] = [] + + # Load from Parquet archives + df_parquet = self._load_from_parquet(archive_path, metric_names) + if df_parquet is not None: + dfs_to_concat.append(df_parquet) + + # Load from WAL + df_wal = self._load_from_wal(wal_path, metric_names) + if df_wal is not None: + dfs_to_concat.append(df_wal) + + return self._combine_dataframes(dfs_to_concat) + + def finish(self) -> None: + """Flush WAL to Parquet archive. + + Force archive any remaining WAL data to Parquet, regardless of threshold. + This ensures all metrics are persisted in Parquet format when the run completes. + """ + wal_path = self._get_wal_path() + try: + has_wal_data = wal_path.stat().st_size > 0 + except FileNotFoundError: + has_wal_data = False + if has_wal_data: + self._try_archive() + + def close(self) -> None: + """Close storage backend. + + This backend doesn't hold long-lived connections, so this is a no-op. + """ + pass diff --git a/src/aspara/tracker/__init__.py b/src/aspara/tracker/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fa5574b5325c3cf0bcb1ff847aa5ee59781d8b60 --- /dev/null +++ b/src/aspara/tracker/__init__.py @@ -0,0 +1,7 @@ +""" +Aspara Tracker API + +Web API service for recording and managing run metrics +""" + +__version__ = "0.1.0" diff --git a/src/aspara/tracker/main.py b/src/aspara/tracker/main.py new file mode 100644 index 0000000000000000000000000000000000000000..c3b47bdc746e75f9ed160c1baeabc02a51799fdc --- /dev/null +++ b/src/aspara/tracker/main.py @@ -0,0 +1,20 @@ +"""Aspara Tracker API application. + +FastAPI application for the tracker API. +""" + +from fastapi import FastAPI + +from aspara.config import is_dev_mode + +from .router import router + +app = FastAPI( + title="Aspara Tracker API", + description="Web API for recording and managing run metrics", + version="0.1.0", + docs_url="/docs/tracker" if is_dev_mode() else None, + redoc_url="/docs/tracker/redoc" if is_dev_mode() else None, +) + +app.include_router(router) diff --git a/src/aspara/tracker/models.py b/src/aspara/tracker/models.py new file mode 100644 index 0000000000000000000000000000000000000000..dfda4f1fe038d2291e67151c72635b77006d9c73 --- /dev/null +++ b/src/aspara/tracker/models.py @@ -0,0 +1,95 @@ +""" +Aspara Tracker data model definitions +""" + +from typing import Any + +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + """Health check response model""" + + status: str = "ok" + + +class MetricsResponse(BaseModel): + """Metrics save response model""" + + status: str = "ok" + + +class MetricsListResponse(BaseModel): + """Metrics list response model""" + + metrics: list[dict[str, Any]] + + +class ProjectsResponse(BaseModel): + """Projects list response model""" + + projects: list[str] + + +class RunsResponse(BaseModel): + """Runs list response model""" + + runs: list[str] + + +class RunCreateRequest(BaseModel): + """Request model for creating a new run via tracker API.""" + + name: str + config: dict[str, Any] = {} + tags: list[str] = [] + notes: str = "" + project_tags: list[str] | None = None + resume: bool = False + + +class RunCreateResponse(BaseModel): + """Response model for run creation endpoint.""" + + status: str = "ok" + project: str + name: str + run_id: str + + +class ArtifactUploadResponse(BaseModel): + """Response model for artifact upload endpoint.""" + + status: str = "ok" + artifact_name: str + file_size: int + + +class ConfigUpdateRequest(BaseModel): + """Request model for updating run config.""" + + config: dict[str, Any] = {} + + +class SummaryUpdateRequest(BaseModel): + """Request model for updating run summary.""" + + summary: dict[str, Any] = {} + + +class FinishRequest(BaseModel): + """Request model for finishing a run.""" + + exit_code: int = 0 + + +class TagsUpdateRequest(BaseModel): + """Request model for updating run tags.""" + + tags: list[str] = [] + + +class StatusResponse(BaseModel): + """Generic status response model.""" + + status: str = "ok" diff --git a/src/aspara/tracker/router.py b/src/aspara/tracker/router.py new file mode 100644 index 0000000000000000000000000000000000000000..d2c1b2f0542d3b27e18628215647f6b3fef272f0 --- /dev/null +++ b/src/aspara/tracker/router.py @@ -0,0 +1,602 @@ +"""Aspara Tracker API router. + +RESTful API endpoints using FastAPI APIRouter. +""" + +import json +import logging +import os +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, Depends, Form, Header, HTTPException, UploadFile + +from aspara.config import get_data_dir, get_resource_limits, is_read_only +from aspara.models import MetricRecord +from aspara.storage import RunMetadataStorage, create_metrics_storage +from aspara.utils import validators +from aspara.utils.metadata import update_project_metadata_tags + +from .models import ( + ArtifactUploadResponse, + ConfigUpdateRequest, + FinishRequest, + HealthResponse, + MetricsResponse, + RunCreateRequest, + RunCreateResponse, + StatusResponse, + SummaryUpdateRequest, + TagsUpdateRequest, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def verify_csrf_header(x_requested_with: str | None = Header(None)) -> None: + """Verify X-Requested-With header for CSRF protection. + + This header cannot be set by cross-origin requests without CORS preflight, + providing protection against CSRF attacks. + + Args: + x_requested_with: The X-Requested-With header value + + Raises: + HTTPException: 403 if header is missing or invalid + """ + if x_requested_with != "XMLHttpRequest": + raise HTTPException( + status_code=403, + detail="Missing or invalid X-Requested-With header", + ) + + +@router.get("/api/v1/health", response_model=HealthResponse, tags=["System"]) +async def health_check() -> HealthResponse: + """Health check endpoint + + Endpoint for checking system status + + Returns: + HealthResponse: Always returns {"status": "ok"} + """ + return HealthResponse() + + +@router.post( + "/api/v1/projects/{project_name}/runs", + response_model=RunCreateResponse, + tags=["Runs"], + dependencies=[Depends(verify_csrf_header)], +) +async def create_run(project_name: str, request: RunCreateRequest) -> RunCreateResponse: + """Create a new run and initialize metadata. + + This endpoint is used by RemoteRun to create run-level metadata and + update project-level metadata tags. It mirrors LocalRun behaviour + for metadata semantics. + + Args: + project_name: Target project name + request: Run creation request containing name, tags, notes, config, and project_tags + + Returns: + RunCreateResponse: Response containing project, name, and run_id + + Raises: + HTTPException: If a run with the same name already exists (409 Conflict) + """ + # Validate input names to prevent path traversal + try: + validators.validate_project_name(project_name) + validators.validate_run_name(request.name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + + if is_read_only(): + return RunCreateResponse( + project=project_name, + name=request.name, + run_id="readonly00000000", + ) + + data_dir = get_data_dir() + base_dir = Path(data_dir) + + # Detect existing run by checking metadata file + metadata_path = base_dir / project_name / f"{request.name}.meta.json" + if metadata_path.exists(): + if not request.resume: + raise HTTPException(status_code=409, detail="Run already exists") + # Resume path: reuse existing run_id and reset finish state. + try: + with open(metadata_path) as f: + existing_meta = json.load(f) + except (json.JSONDecodeError, OSError) as e: + raise HTTPException( + status_code=500, + detail="Failed to read existing run metadata", + ) from e + run_id = existing_meta.get("run_id") or uuid.uuid4().hex[:16] + storage = RunMetadataStorage( + base_dir=str(data_dir), + project_name=project_name, + run_name=request.name, + ) + storage.reset_finish() + if request.config: + storage.update_config(request.config) + if request.project_tags: + update_project_metadata_tags( + base_dir=data_dir, + project_name=project_name, + new_tags=request.project_tags, + ) + return RunCreateResponse( + project=project_name, + name=request.name, + run_id=run_id, + ) + + # Initialize run-level metadata using RunMetadataStorage + storage = RunMetadataStorage( + base_dir=str(data_dir), + project_name=project_name, + run_name=request.name, + ) + + now = int(datetime.now(timezone.utc).timestamp() * 1000) + run_id = uuid.uuid4().hex[:16] # Server always generates run_id + storage.set_init( + run_id=run_id, + tags=request.tags, + notes=request.notes, + timestamp=now, + ) + + if request.config: + storage.update_config(request.config) + + # Update project-level metadata.json with project_tags, if provided + if request.project_tags: + update_project_metadata_tags( + base_dir=data_dir, + project_name=project_name, + new_tags=request.project_tags, + ) + + return RunCreateResponse( + project=project_name, + name=request.name, + run_id=run_id, + ) + + +@router.post( + "/api/v1/projects/{project_name}/runs/{run_name}/metrics", + response_model=MetricsResponse, + tags=["Metrics"], + dependencies=[Depends(verify_csrf_header)], +) +async def save_metrics( + project_name: str, + run_name: str, + data: MetricRecord, +) -> MetricsResponse: + """Endpoint for saving metrics + + Receives and saves run metrics data + + Args: + project_name: Target project name + run_name: Target run name + data: Metrics data to save + + Returns: + MetricsResponse: Response + + Raises: + HTTPException: If validation fails + """ + # Validate input names to prevent path traversal + try: + validators.validate_project_name(project_name) + validators.validate_run_name(run_name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + + if is_read_only(): + return MetricsResponse() + + try: + # Create storage instance for this specific project/run + data_dir = get_data_dir() + storage = create_metrics_storage( + backend=None, + base_dir=str(data_dir), + project_name=project_name, + run_name=run_name, + ) + # Use mode='json' to convert datetime to ISO format string + storage.save(data.model_dump(mode="json")) + return MetricsResponse() + except ValueError as e: + # Validation errors are safe to return + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + # Log the error but don't expose internal details + logger.error(f"Error saving metrics: {e}") + raise HTTPException(status_code=400, detail="Failed to save metrics") from e + + +@router.post( + "/api/v1/projects/{project_name}/runs/{run_name}/artifacts", + response_model=ArtifactUploadResponse, + tags=["Artifacts"], + dependencies=[Depends(verify_csrf_header)], +) +async def upload_artifact( + project_name: str, + run_name: str, + file: UploadFile, + name: str | None = Form(None), + description: str | None = Form(None), + category: str | None = Form(None), +) -> ArtifactUploadResponse: + """Upload an artifact file for a run. + + Args: + project_name: Target project name + run_name: Target run name + file: File to upload + name: Optional custom name for the artifact. If None, uses the filename. + description: Optional description of the artifact + category: Optional category ('code', 'model', 'config', 'data', 'other') + + Returns: + ArtifactUploadResponse: Response with artifact details + + Raises: + HTTPException: If validation fails or file operation fails + """ + try: + # Validate input names to prevent path traversal + try: + validators.validate_project_name(project_name) + validators.validate_run_name(run_name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + + if is_read_only(): + return ArtifactUploadResponse( + artifact_name=name or file.filename or "readonly", + file_size=0, + ) + + # Validate category if provided + if category and category not in ("code", "model", "config", "data", "other"): + raise HTTPException( + status_code=400, + detail=f"Invalid category: {category}. Must be one of: code, model, config, data, other", + ) + + # Determine artifact name + artifact_name = name or file.filename + if not artifact_name: + raise HTTPException(status_code=400, detail="Artifact name cannot be empty") + + # Validate artifact name to prevent path traversal + try: + validators.validate_artifact_name(artifact_name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + + # Set up artifacts directory + data_dir = get_data_dir() + base_dir = Path(data_dir) + artifacts_dir = base_dir / project_name / run_name / "artifacts" + artifacts_dir.mkdir(parents=True, exist_ok=True) + + # Construct destination path and validate it's within artifacts_dir + dest_path = artifacts_dir / artifact_name + try: + validators.validate_safe_path(dest_path, artifacts_dir) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + + # Save uploaded file with streaming size enforcement. + # UploadFile.size may be None under chunked transfer encoding, so + # we cannot rely on it alone. Read in fixed-size chunks and track + # the cumulative bytes written; abort (and clean up the partial + # file) as soon as the limit is exceeded. The limit is the SSOT + # value from ResourceLimits.max_file_size (env-configurable via + # ASPARA_MAX_FILE_SIZE). + max_file_size = get_resource_limits().max_file_size + written = 0 + chunk_size = 1 << 20 # 1 MiB + try: + with open(dest_path, "wb") as f: + while True: + chunk = file.file.read(chunk_size) + if not chunk: + break + written += len(chunk) + if written > max_file_size: + f.close() + dest_path.unlink(missing_ok=True) + raise HTTPException( + status_code=413, + detail=f"File too large: exceeds limit of {max_file_size} bytes", + ) + f.write(chunk) + except HTTPException: + raise + except Exception as e: + # Clean up partial file on any write error + dest_path.unlink(missing_ok=True) + logger.error(f"Error writing artifact {artifact_name}: {e}") + raise HTTPException(status_code=500, detail="Failed to write artifact") from e + + logger.info(f"Uploaded artifact: {artifact_name} to {project_name}/{run_name}") + + # Get file size + file_size = written + + # Prepare artifact metadata + artifact_data = { + "name": artifact_name, + "original_path": file.filename or artifact_name, + "stored_path": os.path.join("artifacts", artifact_name), + "file_size": file_size, + "timestamp": int(datetime.now(timezone.utc).timestamp() * 1000), + } + + if description: + artifact_data["description"] = description + + if category: + artifact_data["category"] = category + + # Save artifact metadata + metadata_storage = RunMetadataStorage( + base_dir=str(data_dir), + project_name=project_name, + run_name=run_name, + ) + metadata_storage.add_artifact(artifact_data) + + return ArtifactUploadResponse( + artifact_name=artifact_name, + file_size=file_size, + ) + except HTTPException: + raise + except Exception as e: + # Log the error but don't expose internal details + logger.error(f"Error uploading artifact: {e}") + raise HTTPException(status_code=500, detail="Failed to upload artifact") from e + + +@router.post( + "/api/v1/projects/{project_name}/runs/{run_name}/config", + response_model=StatusResponse, + tags=["Runs"], + dependencies=[Depends(verify_csrf_header)], +) +async def update_config( + project_name: str, + run_name: str, + request: ConfigUpdateRequest, +) -> StatusResponse: + """Update configuration for a run. + + Args: + project_name: Target project name + run_name: Target run name + request: Config update request containing config dict + + Returns: + StatusResponse: Response with status + + Raises: + HTTPException: If validation fails or run doesn't exist + """ + # Validate input names to prevent path traversal + try: + validators.validate_project_name(project_name) + validators.validate_run_name(run_name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + + if is_read_only(): + return StatusResponse() + + data_dir = get_data_dir() + base_dir = Path(data_dir) + + # Check if run exists + metadata_path = base_dir / project_name / f"{run_name}.meta.json" + if not metadata_path.exists(): + raise HTTPException(status_code=404, detail="Run not found") + + try: + storage = RunMetadataStorage( + base_dir=str(data_dir), + project_name=project_name, + run_name=run_name, + ) + storage.update_config(request.config) + return StatusResponse() + except Exception as e: + logger.error(f"Error updating config: {e}") + raise HTTPException(status_code=500, detail="Failed to update config") from e + + +@router.post( + "/api/v1/projects/{project_name}/runs/{run_name}/summary", + response_model=StatusResponse, + tags=["Runs"], + dependencies=[Depends(verify_csrf_header)], +) +async def update_summary( + project_name: str, + run_name: str, + request: SummaryUpdateRequest, +) -> StatusResponse: + """Update summary for a run. + + Args: + project_name: Target project name + run_name: Target run name + request: Summary update request containing summary dict + + Returns: + StatusResponse: Response with status + + Raises: + HTTPException: If validation fails or run doesn't exist + """ + # Validate input names to prevent path traversal + try: + validators.validate_project_name(project_name) + validators.validate_run_name(run_name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + + if is_read_only(): + return StatusResponse() + + data_dir = get_data_dir() + base_dir = Path(data_dir) + + # Check if run exists + metadata_path = base_dir / project_name / f"{run_name}.meta.json" + if not metadata_path.exists(): + raise HTTPException(status_code=404, detail="Run not found") + + try: + storage = RunMetadataStorage( + base_dir=str(data_dir), + project_name=project_name, + run_name=run_name, + ) + storage.update_summary(request.summary) + return StatusResponse() + except Exception as e: + logger.error(f"Error updating summary: {e}") + raise HTTPException(status_code=500, detail="Failed to update summary") from e + + +@router.post( + "/api/v1/projects/{project_name}/runs/{run_name}/finish", + response_model=StatusResponse, + tags=["Runs"], + dependencies=[Depends(verify_csrf_header)], +) +async def finish_run( + project_name: str, + run_name: str, + request: FinishRequest, +) -> StatusResponse: + """Finish a run. + + Args: + project_name: Target project name + run_name: Target run name + request: Finish request containing exit_code + + Returns: + StatusResponse: Response with status + + Raises: + HTTPException: If validation fails or run doesn't exist + """ + # Validate input names to prevent path traversal + try: + validators.validate_project_name(project_name) + validators.validate_run_name(run_name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + + if is_read_only(): + return StatusResponse() + + data_dir = get_data_dir() + base_dir = Path(data_dir) + + # Check if run exists + metadata_path = base_dir / project_name / f"{run_name}.meta.json" + if not metadata_path.exists(): + raise HTTPException(status_code=404, detail="Run not found") + + try: + storage = RunMetadataStorage( + base_dir=str(data_dir), + project_name=project_name, + run_name=run_name, + ) + now = int(datetime.now(timezone.utc).timestamp() * 1000) + storage.set_finish(exit_code=request.exit_code, timestamp=now) + return StatusResponse() + except Exception as e: + logger.error(f"Error finishing run: {e}") + raise HTTPException(status_code=500, detail="Failed to finish run") from e + + +@router.post( + "/api/v1/projects/{project_name}/runs/{run_name}/tags", + response_model=StatusResponse, + tags=["Runs"], + dependencies=[Depends(verify_csrf_header)], +) +async def update_tags( + project_name: str, + run_name: str, + request: TagsUpdateRequest, +) -> StatusResponse: + """Update tags for a run. + + Args: + project_name: Target project name + run_name: Target run name + request: Tags update request containing the new tag list + + Returns: + StatusResponse: Response with status + + Raises: + HTTPException: If validation fails or run doesn't exist + """ + # Validate input names to prevent path traversal + try: + validators.validate_project_name(project_name) + validators.validate_run_name(run_name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + + if is_read_only(): + return StatusResponse() + + data_dir = get_data_dir() + base_dir = Path(data_dir) + + # Check if run exists + metadata_path = base_dir / project_name / f"{run_name}.meta.json" + if not metadata_path.exists(): + raise HTTPException(status_code=404, detail="Run not found") + + try: + storage = RunMetadataStorage( + base_dir=str(data_dir), + project_name=project_name, + run_name=run_name, + ) + storage.set_tags(request.tags) + return StatusResponse() + except Exception as e: + logger.error(f"Error updating tags: {e}") + raise HTTPException(status_code=500, detail="Failed to update tags") from e diff --git a/src/aspara/tui/__init__.py b/src/aspara/tui/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6548f65a7eccbc758a02c5c5b07a6b8afc0e30b8 --- /dev/null +++ b/src/aspara/tui/__init__.py @@ -0,0 +1,23 @@ +""" +Aspara Terminal UI Dashboard + +A terminal-based dashboard using Textual framework for viewing +projects, runs, and metrics. +""" + +from __future__ import annotations + + +def run_tui(data_dir: str | None = None) -> None: + """Run the Aspara TUI application. + + Args: + data_dir: Data directory path. Defaults to XDG-based default. + """ + from aspara.tui.app import AsparaTUIApp + + app = AsparaTUIApp(data_dir=data_dir) + app.run() + + +__all__ = ["run_tui"] diff --git a/src/aspara/tui/app.py b/src/aspara/tui/app.py new file mode 100644 index 0000000000000000000000000000000000000000..3c2dab7a0e302f8c7fb7f824bd760895d182f4ff --- /dev/null +++ b/src/aspara/tui/app.py @@ -0,0 +1,105 @@ +""" +Aspara TUI Application + +Main application class for the terminal-based dashboard. +""" + +from __future__ import annotations + +from pathlib import Path + +from textual.app import App +from textual.binding import Binding +from textual.theme import Theme + +from aspara.catalog import ProjectCatalog, RunCatalog +from aspara.config import get_data_dir + +# Chart line color - calm asparagus green matching the brand palette +CHART_LINE_COLOR = (90, 139, 111) + +# Aspara theme (matching web dashboard color palette) +ASPARA_THEME = Theme( + name="aspara", + primary="#2C2520", + secondary="#8B7F75", + accent="#CC785C", + foreground="#2C2520", + background="#F5F3F0", + surface="#FDFCFB", + panel="#E6E3E0", + success="#5A8B6F", + error="#C84C3C", + warning="#D4864E", +) + + +class AsparaTUIApp(App[None]): + """Aspara Terminal UI Application. + + A terminal-based dashboard for viewing projects, runs, and metrics. + """ + + TITLE = "Aspara TUI" + CSS_PATH = "styles/app.tcss" + + BINDINGS = [ + Binding("q", "quit", "Quit", show=True, priority=True), + Binding("question_mark", "help", "Help", show=True), + Binding("escape", "back", "Back", show=False), + ] + + def __init__(self, data_dir: str | None = None) -> None: + """Initialize the TUI application. + + Args: + data_dir: Data directory path. Defaults to XDG-based default. + """ + super().__init__() + self._data_dir = Path(data_dir) if data_dir else get_data_dir() + self._project_catalog: ProjectCatalog | None = None + self._run_catalog: RunCatalog | None = None + + # Register and apply Aspara theme + self.register_theme(ASPARA_THEME) + self.theme = "aspara" + + @property + def data_dir(self) -> Path: + """Get the data directory path.""" + return self._data_dir + + @property + def project_catalog(self) -> ProjectCatalog: + """Get or create the project catalog instance.""" + if self._project_catalog is None: + self._project_catalog = ProjectCatalog(self._data_dir) + return self._project_catalog + + @property + def run_catalog(self) -> RunCatalog: + """Get or create the run catalog instance.""" + if self._run_catalog is None: + self._run_catalog = RunCatalog(self._data_dir) + return self._run_catalog + + def on_mount(self) -> None: + """Handle mount event - push the initial screen.""" + from aspara.tui.screens import ProjectsScreen + + self.push_screen(ProjectsScreen()) + + async def action_quit(self) -> None: + """Quit the application.""" + self.exit() + + def action_help(self) -> None: + """Show help screen.""" + from aspara.tui.screens import HelpScreen + + self.push_screen(HelpScreen()) + + async def action_back(self) -> None: + """Go back to previous screen.""" + if len(self.screen_stack) > 1: + self.pop_screen() diff --git a/src/aspara/tui/screens/__init__.py b/src/aspara/tui/screens/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..627230855d41c24ea8d5e575ccf1d86de9157c62 --- /dev/null +++ b/src/aspara/tui/screens/__init__.py @@ -0,0 +1,17 @@ +""" +Aspara TUI Screens + +Screen classes for different views in the TUI application. +""" + +from .help import HelpScreen +from .projects import ProjectsScreen +from .run_detail import RunDetailScreen +from .runs import RunsScreen + +__all__ = [ + "HelpScreen", + "ProjectsScreen", + "RunDetailScreen", + "RunsScreen", +] diff --git a/src/aspara/tui/screens/help.py b/src/aspara/tui/screens/help.py new file mode 100644 index 0000000000000000000000000000000000000000..3040b002b7520cd50d04cd4269e723bf88fe1df4 --- /dev/null +++ b/src/aspara/tui/screens/help.py @@ -0,0 +1,92 @@ +""" +Help Screen + +Displays keybinding help and usage information. +""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, VerticalScroll +from textual.screen import ModalScreen +from textual.widgets import Static + +HELP_TEXT = """\ +[bold]Aspara TUI - Keyboard Shortcuts[/bold] + +[bold underline]Global[/bold underline] + [cyan]q[/] Quit application + [cyan]?[/] Show this help + [cyan]Backspace[/] Go back to previous screen + [cyan]Esc[/] Close modal / Unfocus search + +[bold underline]Navigation (vim-style)[/bold underline] + [cyan]j[/] / [cyan]↓[/] Move down + [cyan]k[/] / [cyan]↑[/] Move up + [cyan]Enter[/] Select / Confirm + [cyan]Tab[/] Focus next widget (e.g. metric charts) + +[bold underline]List Screens (Projects, Runs)[/bold underline] + [cyan]/[/] Focus search input + [cyan]s[/] Toggle sort order + [cyan]Ctrl+r[/] Reload list from disk + +[bold underline]Chart View[/bold underline] + [cyan]h[/] / [cyan]←[/] Pan left + [cyan]l[/] / [cyan]→[/] Pan right + [cyan]+[/] / [cyan]=[/] Zoom in + [cyan]-[/] Zoom out + [cyan]r[/] Reset view + [cyan]g[/] Jump to start + [cyan]G[/] Jump to end + [cyan]w[/] Toggle live watch mode + +[bold underline]Status Icons[/bold underline] + [yellow]ā—[/] Running (WIP) + [green]āœ“[/] Completed + [red]āœ—[/] Failed + [yellow]?[/] Maybe Failed + +Press [cyan]Esc[/] or [cyan]?[/] to close this help. +""" + + +class HelpScreen(ModalScreen[None]): + """Modal screen displaying help information.""" + + BINDINGS = [ + Binding("escape", "dismiss", "Close", show=True), + Binding("question_mark", "dismiss", "Close", show=False), + ] + + DEFAULT_CSS = """ + HelpScreen { + align: center middle; + } + + HelpScreen > Container { + width: 60; + height: auto; + max-height: 80%; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + HelpScreen Static { + width: 100%; + } + """ + + def compose(self) -> ComposeResult: + """Compose the help screen.""" + yield Container( + VerticalScroll( + Static(HELP_TEXT), + ), + ) + + async def action_dismiss(self, result: None = None) -> None: + """Dismiss the help screen.""" + self.dismiss(result) diff --git a/src/aspara/tui/screens/metric_chart.py b/src/aspara/tui/screens/metric_chart.py new file mode 100644 index 0000000000000000000000000000000000000000..5a713548851319f5621f8d57f3cb71a70431ae68 --- /dev/null +++ b/src/aspara/tui/screens/metric_chart.py @@ -0,0 +1,354 @@ +""" +Metric Chart Screen + +Displays a chart for a specific metric using textual-plotext. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, Vertical +from textual.screen import Screen +from textual.widgets import Footer, Header, Static +from textual.worker import Worker, WorkerState + +from aspara.exceptions import RunNotFoundError +from aspara.models import MetricRecord +from aspara.tui.app import CHART_LINE_COLOR +from aspara.tui.widgets import Breadcrumb + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from aspara.tui.app import AsparaTUIApp + +try: + from textual_plotext import PlotextPlot + + HAS_PLOTEXT = True +except ImportError: + HAS_PLOTEXT = False + + +class MetricChartScreen(Screen[None]): + """Screen displaying a chart for a specific metric.""" + + BINDINGS = [ + Binding("h", "pan_left", "Pan Left", show=True), + Binding("l", "pan_right", "Pan Right", show=True), + Binding("left", "pan_left", "Pan Left", show=False), + Binding("right", "pan_right", "Pan Right", show=False), + Binding("plus", "zoom_in", "Zoom In", show=True), + Binding("equals", "zoom_in", "Zoom In", show=False), + Binding("minus", "zoom_out", "Zoom Out", show=True), + Binding("r", "reset_view", "Reset", show=True), + Binding("w", "toggle_watch", "Watch", show=True), + Binding("g", "jump_to_start", "Start", show=False), + Binding("G", "jump_to_end", "End", show=False), + Binding("backspace", "go_back", "Back", show=True), + ] + + def __init__(self, project_name: str, run_name: str, metric_name: str) -> None: + super().__init__() + self._project_name = project_name + self._run_name = run_name + self._metric_name = metric_name + self._steps: list[int] = [] + self._values: list[float] = [] + self._view_start: int | None = None + self._view_end: int | None = None + self._watching: bool = False + self._watch_worker: Worker | None = None + self._last_timestamp: datetime = datetime.now(timezone.utc) + + @property + def tui_app(self) -> AsparaTUIApp: + """Get the typed app instance.""" + from aspara.tui.app import AsparaTUIApp + + assert isinstance(self.app, AsparaTUIApp) + return self.app + + def compose(self) -> ComposeResult: + """Compose the screen layout.""" + yield Header() + yield Container( + Breadcrumb(["Projects", self._project_name, self._run_name, self._metric_name]), + Vertical( + self._create_chart_widget(), + id="chart-container", + classes="chart-container", + ), + Static(id="current-value", classes="current-value"), + Static(id="watch-status", classes="watch-status"), + classes="main-container", + ) + yield Footer() + + def _create_chart_widget(self) -> Static | PlotextPlot: + """Create the chart widget. + + Returns: + PlotextPlot if available, otherwise a Static placeholder. + """ + if HAS_PLOTEXT: + return PlotextPlot(id="chart") + else: + return Static("textual-plotext not installed. Install with: pip install textual-plotext", id="chart") + + def on_mount(self) -> None: + """Handle mount event - load metric data.""" + self._load_metric_data() + self._update_chart() + + def _load_metric_data(self) -> None: + """Load metric data from catalog.""" + try: + df = self.tui_app.run_catalog.load_metrics(self._project_name, self._run_name) + if df is not None and len(df) > 0 and self._metric_name in df.columns: + filtered = df.filter(df[self._metric_name].is_not_null()) + if len(filtered) > 0: + if "step" in filtered.columns: + steps = filtered["step"].to_list() + self._steps = [s if s is not None else i for i, s in enumerate(steps)] + else: + self._steps = list(range(len(filtered))) + self._values = filtered[self._metric_name].to_list() + + if "timestamp" in filtered.columns: + timestamps = filtered["timestamp"].to_list() + if timestamps: + last_ts = timestamps[-1] + if last_ts is not None: + self._last_timestamp = last_ts + + self._apply_lttb_if_needed() + except (FileNotFoundError, RunNotFoundError): + logger.debug("Metric data not found for %s/%s", self._project_name, self._run_name) + except OSError as e: + logger.error("Failed to load metric data: %s", e) + self.notify("Failed to load metric data", severity="error") + + def _apply_lttb_if_needed(self) -> None: + """Apply LTTB downsampling if data is large.""" + threshold = 1000 + if len(self._steps) > threshold: + try: + from lttb import downsample + + data = list(zip(self._steps, self._values, strict=True)) + downsampled = downsample(data, threshold) + self._steps = [int(d[0]) for d in downsampled] + self._values = [float(d[1]) for d in downsampled] + except ImportError: + pass + + def _update_chart(self) -> None: + """Update the chart display.""" + if not HAS_PLOTEXT: + return + + chart = self.query_one("#chart", PlotextPlot) + + if not self._steps or not self._values: + chart.plt.clear_figure() + chart.plt.title("No data") + chart.refresh() + return + + view_steps = self._steps + view_values = self._values + if self._view_start is not None and self._view_end is not None: + indices = [i for i, s in enumerate(self._steps) if self._view_start <= s <= self._view_end] + if indices: + view_steps = [self._steps[i] for i in indices] + view_values = [self._values[i] for i in indices] + + chart.plt.clear_figure() + chart.plt.title(self._metric_name) + chart.plt.xlabel("step") + chart.plt.plot(view_steps, view_values, color=CHART_LINE_COLOR) + chart.refresh() + + if self._values: + current_step = self._steps[-1] if self._steps else 0 + current_value = self._values[-1] + value_widget = self.query_one("#current-value", Static) + value_widget.update(f"Current: step={current_step}, value={current_value:.6g}") + + def _get_view_range(self) -> tuple[int, int]: + """Get current view range. + + Returns: + Tuple of (start, end) step values. + """ + if self._view_start is not None and self._view_end is not None: + return (self._view_start, self._view_end) + if self._steps: + return (min(self._steps), max(self._steps)) + return (0, 100) + + def action_pan_left(self) -> None: + """Pan chart view left.""" + if not self._steps: + return + + start, end = self._get_view_range() + width = end - start + pan_amount = max(1, width // 10) + + new_start = max(min(self._steps), start - pan_amount) + new_end = new_start + width + + self._view_start = new_start + self._view_end = new_end + self._update_chart() + + def action_pan_right(self) -> None: + """Pan chart view right.""" + if not self._steps: + return + + start, end = self._get_view_range() + width = end - start + pan_amount = max(1, width // 10) + + new_end = min(max(self._steps), end + pan_amount) + new_start = new_end - width + + self._view_start = new_start + self._view_end = new_end + self._update_chart() + + def action_zoom_in(self) -> None: + """Zoom in on chart.""" + if not self._steps: + return + + start, end = self._get_view_range() + width = end - start + if width <= 10: + return + + center = (start + end) // 2 + new_width = max(10, width // 2) + + self._view_start = center - new_width // 2 + self._view_end = center + new_width // 2 + self._update_chart() + + def action_zoom_out(self) -> None: + """Zoom out on chart.""" + if not self._steps: + return + + start, end = self._get_view_range() + width = end - start + center = (start + end) // 2 + new_width = width * 2 + + min_step = min(self._steps) + max_step = max(self._steps) + + self._view_start = max(min_step, center - new_width // 2) + self._view_end = min(max_step, center + new_width // 2) + + if self._view_start == min_step and self._view_end == max_step: + self._view_start = None + self._view_end = None + + self._update_chart() + + def action_reset_view(self) -> None: + """Reset chart view to show all data.""" + self._view_start = None + self._view_end = None + self._update_chart() + + def action_jump_to_start(self) -> None: + """Jump to the beginning of the data.""" + if not self._steps: + return + + start, end = self._get_view_range() + width = end - start + min_step = min(self._steps) + + self._view_start = min_step + self._view_end = min_step + width + self._update_chart() + + def action_jump_to_end(self) -> None: + """Jump to the end of the data.""" + if not self._steps: + return + + start, end = self._get_view_range() + width = end - start + max_step = max(self._steps) + + self._view_start = max_step - width + self._view_end = max_step + self._update_chart() + + def action_toggle_watch(self) -> None: + """Toggle live watch mode.""" + self._watching = not self._watching + status_widget = self.query_one("#watch-status", Static) + + if self._watching: + status_widget.update("[green]ā— Watching for updates...[/]") + self._start_watching() + else: + status_widget.update("") + self._stop_watching() + + def _start_watching(self) -> None: + """Start watching for metric updates.""" + self._watch_worker = self.run_worker(self._watch_metrics(), exclusive=True) + + def _stop_watching(self) -> None: + """Stop watching for metric updates.""" + if self._watch_worker is not None and self._watch_worker.state == WorkerState.RUNNING: + self._watch_worker.cancel() + self._watch_worker = None + + async def _watch_metrics(self) -> None: + """Watch for metric updates using catalog subscribe.""" + try: + async for record in self.tui_app.run_catalog.subscribe( + {self._project_name: [self._run_name]}, + self._last_timestamp, + ): + if isinstance(record, MetricRecord) and self._metric_name in record.metrics: + value = record.metrics[self._metric_name] + step = record.step if record.step is not None else len(self._steps) + + self._steps.append(step) + self._values.append(float(value)) + self._last_timestamp = record.timestamp + + self._apply_lttb_if_needed() + self.app.call_from_thread(self._update_chart) + + await asyncio.sleep(0.1) + except asyncio.CancelledError: + pass + except (FileNotFoundError, RunNotFoundError): + logger.debug("Watch stopped: data not found") + except OSError as e: + logger.warning("Watch stopped due to error: %s", e) + + def on_unmount(self) -> None: + """Handle unmount - stop watching.""" + self._stop_watching() + + def action_go_back(self) -> None: + """Go back to previous screen.""" + self.app.pop_screen() diff --git a/src/aspara/tui/screens/projects.py b/src/aspara/tui/screens/projects.py new file mode 100644 index 0000000000000000000000000000000000000000..f616d134fdd887f14832e094ff0a6da846dbe7a5 --- /dev/null +++ b/src/aspara/tui/screens/projects.py @@ -0,0 +1,236 @@ +""" +Projects Screen + +Displays list of all projects with search and sort functionality. +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import TYPE_CHECKING + +from textual import events, on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, Vertical +from textual.screen import Screen +from textual.widgets import DataTable, Footer, Header, Input, Static + +if TYPE_CHECKING: + from aspara.catalog import ProjectInfo + from aspara.tui.app import AsparaTUIApp + +logger = logging.getLogger(__name__) + + +class ProjectsScreen(Screen[None]): + """Screen displaying list of all projects.""" + + BINDINGS = [ + Binding("slash", "focus_search", "Search", show=True), + Binding("tab", "focus_search", "Search", show=False), + Binding("s", "toggle_sort", "Sort", show=True), + Binding("j", "cursor_down", "Down", show=False), + Binding("k", "cursor_up", "Up", show=False), + Binding("ctrl+r", "reload", "Reload", show=True), + Binding("escape", "unfocus_search", "Clear focus", show=False), + ] + + def __init__(self) -> None: + super().__init__() + self._projects: list[ProjectInfo] = [] + self._sort_key: str = "name" + self._sort_reverse: bool = False + + @property + def tui_app(self) -> AsparaTUIApp: + """Get the typed app instance.""" + from aspara.tui.app import AsparaTUIApp + + assert isinstance(self.app, AsparaTUIApp) + return self.app + + def compose(self) -> ComposeResult: + """Compose the screen layout.""" + yield Header() + yield Container( + Static("Projects", classes="screen-title"), + Input(placeholder="Search projects...", id="search-input"), + Vertical( + DataTable(id="projects-table", cursor_type="row"), + classes="table-container", + ), + classes="main-container", + ) + yield Footer() + + def on_mount(self) -> None: + """Handle mount event - load projects.""" + table = self.query_one("#projects-table", DataTable) + table.add_column("Name") + table.add_column("Runs") + table.add_column("Last Update") + table.add_column("Tags") + self._load_projects() + self._update_column_widths() + table.focus() + + def on_resize(self, event: events.Resize) -> None: + """Handle resize event - adjust column widths.""" + self._update_column_widths() + + def _load_projects(self, filter_text: str = "") -> None: + """Load and display projects. + + Args: + filter_text: Optional filter text for project names. + """ + try: + self._projects = self.tui_app.project_catalog.get_projects() + except FileNotFoundError: + logger.debug("Data directory not found: %s", self.tui_app.data_dir) + self._projects = [] + except PermissionError as e: + logger.warning("Permission denied loading projects: %s", e) + self.notify(f"Permission denied: {self.tui_app.data_dir}", severity="error") + self._projects = [] + except OSError as e: + logger.error("Failed to load projects: %s", e) + self.notify(f"Failed to load projects: {e}", severity="error") + self._projects = [] + + if filter_text: + filter_lower = filter_text.lower() + self._projects = [p for p in self._projects if filter_lower in p.name.lower()] + + self._sort_projects() + self._update_table() + + def _sort_projects(self) -> None: + """Sort projects by current sort key.""" + if self._sort_key == "name": + self._projects.sort(key=lambda p: p.name.lower(), reverse=self._sort_reverse) + elif self._sort_key == "runs": + self._projects.sort(key=lambda p: p.run_count, reverse=self._sort_reverse) + elif self._sort_key == "last_update": + self._projects.sort(key=lambda p: p.last_update or datetime.min, reverse=self._sort_reverse) + + def _update_table(self) -> None: + """Update the data table with current projects.""" + table = self.query_one("#projects-table", DataTable) + table.clear() + + for project in self._projects: + last_update = project.last_update.strftime("%Y-%m-%d %H:%M") if project.last_update else "-" + tags = self._get_project_tags(project.name) + tags_str = " ".join(f"\\[{tag}]" for tag in tags) if tags else "-" + + table.add_row( + project.name, + str(project.run_count), + last_update, + tags_str, + key=project.name, + ) + + if not self._projects: + data_dir = self.tui_app.data_dir + self.notify( + f"No projects found in {data_dir}. Create one with aspara.init(project=...)", + timeout=8, + ) + + def _update_column_widths(self) -> None: + """Update column widths to fill available space.""" + table = self.query_one("#projects-table", DataTable) + + # Calculate available width (subtract border/padding) + available_width = table.size.width - 4 + + if available_width <= 0 or not table.columns: + return + + # Distribution ratios: Name(3), Runs(1), Last Update(2), Tags(4) + ratios = [3, 1, 2, 4] + total_ratio = sum(ratios) + + for column_key, ratio in zip(table.columns, ratios, strict=True): + width = max(8, (available_width * ratio) // total_ratio) + table.columns[column_key].width = width + table.columns[column_key].auto_width = False + + table.refresh() + + def _get_project_tags(self, project_name: str) -> list[str]: + """Get unique tags across all runs in a project. + + Args: + project_name: Name of the project. + + Returns: + List of unique tag names. + """ + try: + metadata = self.tui_app.project_catalog.get_metadata(project_name) + return metadata.get("tags", []) + except (FileNotFoundError, KeyError): + return [] + except OSError as e: + logger.debug("Failed to get tags for %s: %s", project_name, e) + return [] + + @on(Input.Changed, "#search-input") + def on_search_changed(self, event: Input.Changed) -> None: + """Handle search input change.""" + self._load_projects(filter_text=event.value) + + @on(DataTable.RowSelected, "#projects-table") + def on_project_selected(self, event: DataTable.RowSelected) -> None: + """Handle project selection.""" + if event.row_key and event.row_key.value: + project_name = str(event.row_key.value) + from aspara.tui.screens import RunsScreen + + self.app.push_screen(RunsScreen(project_name)) + + def action_focus_search(self) -> None: + """Focus the search input.""" + self.query_one("#search-input", Input).focus() + + def action_toggle_sort(self) -> None: + """Toggle sort between name, runs, and last_update.""" + sort_keys = ["name", "runs", "last_update"] + current_idx = sort_keys.index(self._sort_key) + if self._sort_reverse: + self._sort_key = sort_keys[(current_idx + 1) % len(sort_keys)] + self._sort_reverse = False + else: + self._sort_reverse = True + + self._sort_projects() + self._update_table() + self.notify(f"Sorted by {self._sort_key} ({'desc' if self._sort_reverse else 'asc'})") + + def action_cursor_down(self) -> None: + """Move cursor down in table.""" + table = self.query_one("#projects-table", DataTable) + table.action_cursor_down() + + def action_cursor_up(self) -> None: + """Move cursor up in table.""" + table = self.query_one("#projects-table", DataTable) + table.action_cursor_up() + + def action_reload(self) -> None: + """Reload the project list from disk.""" + search_input = self.query_one("#search-input", Input) + self._load_projects(filter_text=search_input.value) + self.notify("Projects reloaded") + + def action_unfocus_search(self) -> None: + """Remove focus from search input (Escape key).""" + search_input = self.query_one("#search-input", Input) + if search_input.has_focus: + table = self.query_one("#projects-table", DataTable) + table.focus() diff --git a/src/aspara/tui/screens/run_detail.py b/src/aspara/tui/screens/run_detail.py new file mode 100644 index 0000000000000000000000000000000000000000..9033db3bbf50692b244ea8223cec95f27642bc3a --- /dev/null +++ b/src/aspara/tui/screens/run_detail.py @@ -0,0 +1,234 @@ +""" +Run Detail Screen + +Displays detailed information about a specific run. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, Horizontal, Vertical, VerticalScroll +from textual.screen import Screen +from textual.widgets import Footer, Header, Static + +from aspara.exceptions import RunNotFoundError +from aspara.models import RunStatus +from aspara.tui.widgets import Breadcrumb, MetricsGridWidget + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from aspara.catalog import RunInfo + from aspara.tui.app import AsparaTUIApp + + +class RunDetailScreen(Screen[None]): + """Screen displaying details of a specific run.""" + + BINDINGS = [ + Binding("backspace", "go_back", "Back", show=True), + Binding("j", "scroll_down", "Down", show=False), + Binding("k", "scroll_up", "Up", show=False), + ] + + def __init__(self, project_name: str, run_name: str) -> None: + super().__init__() + self._project_name = project_name + self._run_name = run_name + self._run_info: RunInfo | None = None + self._metrics: dict[str, float] = {} + + @property + def tui_app(self) -> AsparaTUIApp: + """Get the typed app instance.""" + from aspara.tui.app import AsparaTUIApp + + assert isinstance(self.app, AsparaTUIApp) + return self.app + + def compose(self) -> ComposeResult: + """Compose the screen layout.""" + yield Header() + yield Container( + Breadcrumb(["Projects", self._project_name, self._run_name]), + Horizontal( + Vertical( + Static("Run Info", classes="section-title"), + Static(id="run-info-content", classes="info-content"), + classes="info-panel", + ), + Vertical( + Static("Parameters", classes="section-title"), + Static(id="params-content", classes="info-content"), + classes="info-panel", + ), + classes="info-row", + ), + Vertical( + Static("Metrics (Tab to focus, Enter to view detail)", classes="section-title"), + VerticalScroll( + Container(id="metrics-grid-container"), + classes="metrics-scroll", + ), + classes="metrics-container", + ), + classes="main-container", + ) + yield Footer() + + def on_mount(self) -> None: + """Handle mount event - load run details.""" + self._load_run_info() + self._load_metrics() + + def _load_run_info(self) -> None: + """Load and display run information.""" + try: + self._run_info = self.tui_app.run_catalog.get(self._project_name, self._run_name) + except RunNotFoundError: + logger.debug("Run not found: %s/%s", self._project_name, self._run_name) + self._run_info = None + except FileNotFoundError: + logger.debug("Data file not found for run: %s/%s", self._project_name, self._run_name) + self._run_info = None + except OSError as e: + logger.error("Failed to load run info: %s", e) + self.notify(f"Failed to load run info: {e}", severity="error") + self._run_info = None + + info_widget = self.query_one("#run-info-content", Static) + + if self._run_info is None: + info_widget.update("Run not found") + return + + status_text = self._get_status_text(self._run_info.status) + tags = self._run_info.tags + tags_text = " ".join(f"[{tag}]" for tag in tags) if tags else "-" + + notes = self._get_run_notes() + notes_text = notes[:100] + "..." if len(notes) > 100 else notes if notes else "-" + + info_widget.update(f"Status: {status_text}\nTags: {tags_text}\nNote: {notes_text}") + + params_widget = self.query_one("#params-content", Static) + params = self._get_run_params() + if params: + params_text = "\n".join(f"{k}: {v}" for k, v in list(params.items())[:10]) + if len(params) > 10: + params_text += f"\n... and {len(params) - 10} more" + else: + params_text = "-" + params_widget.update(params_text) + + def _get_status_text(self, status: RunStatus) -> str: + """Get colored status text. + + Args: + status: Run status. + + Returns: + Colored status text. + """ + icons = { + RunStatus.WIP: "[yellow]ā— Running[/]", + RunStatus.COMPLETED: "[green]āœ“ Completed[/]", + RunStatus.FAILED: "[red]āœ— Failed[/]", + RunStatus.MAYBE_FAILED: "[yellow]? Maybe Failed[/]", + } + return icons.get(status, "Unknown") + + def _get_run_notes(self) -> str: + """Get notes from run metadata. + + Returns: + Notes string or empty string. + """ + try: + metadata = self.tui_app.run_catalog.get_metadata(self._project_name, self._run_name) + return metadata.get("notes", "") + except (FileNotFoundError, KeyError, RunNotFoundError): + return "" + except OSError as e: + logger.debug("Failed to get notes for %s/%s: %s", self._project_name, self._run_name, e) + return "" + + def _get_run_params(self) -> dict[str, str]: + """Get parameters from run metadata. + + Returns: + Dictionary of parameters. + """ + try: + metadata = self.tui_app.run_catalog.get_metadata(self._project_name, self._run_name) + return metadata.get("params", {}) + except (FileNotFoundError, KeyError, RunNotFoundError): + return {} + except OSError as e: + logger.debug("Failed to get params for %s/%s: %s", self._project_name, self._run_name, e) + return {} + + def _load_metrics(self) -> None: + """Load and display metrics as a subplot grid.""" + container = self.query_one("#metrics-grid-container", Container) + metrics_data: list[tuple[str, list[int], list[float]]] = [] + + try: + df = self.tui_app.run_catalog.load_metrics(self._project_name, self._run_name) + if df is not None and len(df) > 0: + metric_cols = [c for c in df.columns if c not in ("timestamp", "step")] + + for col in sorted(metric_cols): + filtered = df.filter(df[col].is_not_null()) + if len(filtered) == 0: + continue + + if "step" in filtered.columns: + steps_raw = filtered["step"].to_list() + steps = [s if s is not None else i for i, s in enumerate(steps_raw)] + else: + steps = list(range(len(filtered))) + + values = filtered[col].to_list() + values = [float(v) if v is not None else 0.0 for v in values] + + if values: + self._metrics[col] = values[-1] + metrics_data.append((col, steps, values)) + except (FileNotFoundError, RunNotFoundError): + logger.debug("Metrics not found for %s/%s", self._project_name, self._run_name) + except OSError as e: + logger.error("Failed to load metrics: %s", e) + self.notify(f"Failed to load metrics: {e}", severity="error") + + if metrics_data: + grid = MetricsGridWidget(metrics_data, id="metrics-grid") + container.mount(grid) + else: + container.mount(Static("No metrics available", id="no-metrics")) + + @on(MetricsGridWidget.MetricSelected) + def on_chart_selected(self, event: MetricsGridWidget.MetricSelected) -> None: + """Handle chart selection.""" + from aspara.tui.screens.metric_chart import MetricChartScreen + + self.app.push_screen(MetricChartScreen(self._project_name, self._run_name, event.metric_name)) + + def action_scroll_down(self) -> None: + """Scroll the metrics container down.""" + scroll = self.query_one(".metrics-scroll", VerticalScroll) + scroll.scroll_down() + + def action_scroll_up(self) -> None: + """Scroll the metrics container up.""" + scroll = self.query_one(".metrics-scroll", VerticalScroll) + scroll.scroll_up() + + def action_go_back(self) -> None: + """Go back to previous screen.""" + self.app.pop_screen() diff --git a/src/aspara/tui/screens/runs.py b/src/aspara/tui/screens/runs.py new file mode 100644 index 0000000000000000000000000000000000000000..3449a5b727bb8163c4e984195331e197f099ea9b --- /dev/null +++ b/src/aspara/tui/screens/runs.py @@ -0,0 +1,253 @@ +""" +Runs Screen + +Displays list of runs for a specific project. +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import TYPE_CHECKING + +from textual import events, on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, Vertical +from textual.screen import Screen +from textual.widgets import DataTable, Footer, Header, Input, Static + +from aspara.exceptions import ProjectNotFoundError +from aspara.models import RunStatus +from aspara.tui.widgets import Breadcrumb + +if TYPE_CHECKING: + from aspara.catalog import RunInfo + from aspara.tui.app import AsparaTUIApp + +logger = logging.getLogger(__name__) + + +class RunsScreen(Screen[None]): + """Screen displaying list of runs for a project.""" + + BINDINGS = [ + Binding("slash", "focus_search", "Search", show=True), + Binding("tab", "focus_search", "Search", show=False), + Binding("s", "toggle_sort", "Sort", show=True), + Binding("j", "cursor_down", "Down", show=False), + Binding("k", "cursor_up", "Up", show=False), + Binding("backspace", "go_back", "Back", show=True), + Binding("ctrl+r", "reload", "Reload", show=True), + Binding("escape", "unfocus_search", "Clear focus", show=False), + ] + + def __init__(self, project_name: str) -> None: + super().__init__() + self._project_name = project_name + self._runs: list[RunInfo] = [] + self._sort_key: str = "last_update" + self._sort_reverse: bool = True + + @property + def tui_app(self) -> AsparaTUIApp: + """Get the typed app instance.""" + from aspara.tui.app import AsparaTUIApp + + assert isinstance(self.app, AsparaTUIApp) + return self.app + + def compose(self) -> ComposeResult: + """Compose the screen layout.""" + yield Header() + yield Container( + Breadcrumb(["Projects", self._project_name]), + Input(placeholder="Search runs...", id="search-input"), + Vertical( + DataTable(id="runs-table", cursor_type="row"), + classes="table-container", + ), + Static( + "Status: [yellow]ā—[/] Running [green]āœ“[/] Completed [red]āœ—[/] Failed [yellow]?[/] Maybe Failed", + classes="status-legend", + ), + classes="main-container", + ) + yield Footer() + + def on_mount(self) -> None: + """Handle mount event - load runs.""" + table = self.query_one("#runs-table", DataTable) + table.add_column("Status") + table.add_column("Name") + table.add_column("Last Update") + table.add_column("Tags") + self._load_runs() + self._update_column_widths() + table.focus() + + def on_resize(self, event: events.Resize) -> None: + """Handle resize event - adjust column widths.""" + self._update_column_widths() + + def _update_column_widths(self) -> None: + """Update column widths to fill available space.""" + table = self.query_one("#runs-table", DataTable) + + # Calculate available width (subtract border/padding) + available_width = table.size.width - 4 + + if available_width <= 0 or not table.columns: + return + + # Distribution ratios: Status(1), Name(3), Last Update(2), Tags(3) + ratios = [1, 3, 2, 3] + total_ratio = sum(ratios) + + for column_key, ratio in zip(table.columns, ratios, strict=True): + width = max(6, (available_width * ratio) // total_ratio) + table.columns[column_key].width = width + table.columns[column_key].auto_width = False + + table.refresh() + + def _load_runs(self, filter_text: str = "") -> None: + """Load and display runs. + + Args: + filter_text: Optional filter text for run names. + """ + try: + self._runs = self.tui_app.run_catalog.get_runs(self._project_name) + except ProjectNotFoundError: + logger.debug("Project not found: %s", self._project_name) + self._runs = [] + except FileNotFoundError: + logger.debug("Data directory not found: %s", self.tui_app.data_dir) + self._runs = [] + except PermissionError as e: + logger.warning("Permission denied loading runs: %s", e) + self.notify(f"Permission denied: {self.tui_app.data_dir}", severity="error") + self._runs = [] + except OSError as e: + logger.error("Failed to load runs: %s", e) + self.notify(f"Failed to load runs: {e}", severity="error") + self._runs = [] + + if filter_text: + filter_lower = filter_text.lower() + self._runs = [r for r in self._runs if filter_lower in r.name.lower()] + + self._sort_runs() + self._update_table() + + def _sort_runs(self) -> None: + """Sort runs by current sort key.""" + if self._sort_key == "name": + self._runs.sort(key=lambda r: r.name.lower(), reverse=self._sort_reverse) + elif self._sort_key == "last_update": + self._runs.sort(key=lambda r: r.last_update or datetime.min, reverse=self._sort_reverse) + elif self._sort_key == "status": + status_order = { + RunStatus.WIP: 0, + RunStatus.COMPLETED: 1, + RunStatus.MAYBE_FAILED: 2, + RunStatus.FAILED: 3, + } + self._runs.sort(key=lambda r: status_order.get(r.status, 99), reverse=self._sort_reverse) + + def _update_table(self) -> None: + """Update the data table with current runs.""" + table = self.query_one("#runs-table", DataTable) + table.clear() + + for run in self._runs: + status_icon = self._get_status_icon(run.status) + last_update = run.last_update.strftime("%Y-%m-%d %H:%M") if run.last_update else "-" + tags_str = " ".join(f"\\[{tag}]" for tag in run.tags) if run.tags else "-" + + table.add_row( + status_icon, + run.name, + last_update, + tags_str, + key=run.name, + ) + + def _get_status_icon(self, status: RunStatus) -> str: + """Get status icon with color markup. + + Args: + status: Run status. + + Returns: + Colored status icon string. + """ + icons = { + RunStatus.WIP: "[yellow]ā—[/]", + RunStatus.COMPLETED: "[green]āœ“[/]", + RunStatus.FAILED: "[red]āœ—[/]", + RunStatus.MAYBE_FAILED: "[yellow]?[/]", + } + return icons.get(status, "[white]?[/]") + + @on(Input.Changed, "#search-input") + def on_search_changed(self, event: Input.Changed) -> None: + """Handle search input change.""" + self._load_runs(filter_text=event.value) + + @on(DataTable.RowSelected, "#runs-table") + def on_run_selected(self, event: DataTable.RowSelected) -> None: + """Handle run selection.""" + if event.row_key and event.row_key.value: + run_name = str(event.row_key.value) + from aspara.tui.screens import RunDetailScreen + + self.app.push_screen(RunDetailScreen(self._project_name, run_name)) + + def action_focus_search(self) -> None: + """Focus the search input.""" + self.query_one("#search-input", Input).focus() + + def action_toggle_sort(self) -> None: + """Toggle sort between name, metrics, last_update, and status.""" + sort_keys = ["last_update", "name", "status"] + current_idx = sort_keys.index(self._sort_key) + if self._sort_reverse: + self._sort_key = sort_keys[(current_idx + 1) % len(sort_keys)] + self._sort_reverse = False + else: + self._sort_reverse = True + + self._sort_runs() + self._update_table() + self.notify(f"Sorted by {self._sort_key} ({'desc' if self._sort_reverse else 'asc'})") + + def action_cursor_down(self) -> None: + """Move cursor down in table.""" + table = self.query_one("#runs-table", DataTable) + table.action_cursor_down() + + def action_cursor_up(self) -> None: + """Move cursor up in table.""" + table = self.query_one("#runs-table", DataTable) + table.action_cursor_up() + + def action_reload(self) -> None: + """Reload the run list from disk.""" + search_input = self.query_one("#search-input", Input) + self._load_runs(filter_text=search_input.value) + self.notify("Runs reloaded") + + def action_go_back(self) -> None: + """Go back to previous screen if not editing.""" + if isinstance(self.app.focused, Input): + return # Let Input handle backspace + self.app.pop_screen() + + def action_unfocus_search(self) -> None: + """Remove focus from search input (Escape key).""" + search_input = self.query_one("#search-input", Input) + if search_input.has_focus: + table = self.query_one("#runs-table", DataTable) + table.focus() diff --git a/src/aspara/tui/styles/app.tcss b/src/aspara/tui/styles/app.tcss new file mode 100644 index 0000000000000000000000000000000000000000..9aead93351b575586377e8939dc06ea542e0de13 --- /dev/null +++ b/src/aspara/tui/styles/app.tcss @@ -0,0 +1,190 @@ +/* Aspara TUI Styles */ + +/* Spacing scale */ +$space-sm: 1; +$space-md: 2; + +/* Global */ +Screen { + background: $surface; +} + +/* Main container */ +.main-container { + width: 100%; + height: 100%; + padding: $space-sm $space-md; + min-width: 60; +} + +/* Screen title */ +.screen-title { + width: 100%; + height: 1; + text-style: bold; + margin-bottom: $space-sm; +} + +/* Section title */ +.section-title { + text-style: bold underline; + margin-bottom: $space-sm; +} + +/* Search input */ +#search-input { + width: 100%; + margin-bottom: $space-sm; +} + +/* Table container */ +.table-container { + width: 100%; + height: 1fr; + border: solid $primary; +} + +/* DataTable styling */ +DataTable { + width: 100%; + height: 100%; +} + +DataTable > .datatable--header { + text-style: bold; + background: $primary; + color: $background; +} + +DataTable > .datatable--cursor { + background: $accent; +} + +/* Status legend */ +.status-legend { + width: 100%; + height: 1; + margin-top: $space-sm; + text-align: center; + color: $text-muted; +} + +/* Info panels (2-column layout) */ +.info-row { + width: 100%; + height: auto; + max-height: 40%; + margin-bottom: $space-sm; +} + +.info-panel { + width: 1fr; + height: 100%; + padding: $space-sm; + border: solid $primary; + margin: 0 $space-sm 0 0; +} + +.info-panel:last-child { + margin-right: 0; +} + +.info-content { + width: 100%; + height: auto; +} + +/* Metrics container */ +.metrics-container { + width: 100%; + height: 1fr; + border: solid $primary; +} + +/* Chart container */ +.chart-container { + width: 100%; + height: 1fr; + border: solid $primary; + padding: $space-sm; +} + +#chart { + width: 100%; + height: 100%; +} + +/* Current value display */ +.current-value { + width: 100%; + height: 1; + margin-top: $space-sm; + text-align: center; +} + +/* Watch status */ +.watch-status { + width: 100%; + height: 1; + text-align: center; +} + +/* Metrics scroll container */ +.metrics-scroll { + width: 100%; + height: 1fr; +} + +/* Mini chart widget */ +MiniChartWidget { + height: 22; + min-width: 30; + border: solid $panel; + margin: $space-sm; +} + +MiniChartWidget:focus { + border: solid $accent; +} + +MiniChartWidget PlotextPlot { + width: 1fr; + height: 18; +} + +/* Metrics grid widget */ +MetricsGridWidget { + width: 100%; + height: auto; +} + +#metrics-rows { + width: 100%; + height: auto; +} + +.metrics-row { + width: 100%; + height: auto; +} + +.chart-cell { + width: 1fr; + height: 14; + border: solid $panel; + margin: 0 $space-sm $space-sm 0; +} + +.chart-cell:focus { + border: solid $accent; +} + +.chart-cell PlotextPlot { + width: 100%; + height: 12; +} + +#metrics-grid-container { + width: 100%; + height: auto; +} diff --git a/src/aspara/tui/widgets/__init__.py b/src/aspara/tui/widgets/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..cfebd548d8c48b855524bc11cef7a202e44ef00a --- /dev/null +++ b/src/aspara/tui/widgets/__init__.py @@ -0,0 +1,11 @@ +""" +Aspara TUI Widgets + +Custom widget classes for the TUI application. +""" + +from .breadcrumb import Breadcrumb +from .metrics_grid import MetricsGridWidget +from .mini_chart import MiniChartWidget + +__all__ = ["Breadcrumb", "MetricsGridWidget", "MiniChartWidget"] diff --git a/src/aspara/tui/widgets/breadcrumb.py b/src/aspara/tui/widgets/breadcrumb.py new file mode 100644 index 0000000000000000000000000000000000000000..961279ae7b2d49cd874cf32604efb94ddc602f6f --- /dev/null +++ b/src/aspara/tui/widgets/breadcrumb.py @@ -0,0 +1,65 @@ +""" +Breadcrumb Widget + +Navigation breadcrumb showing current location in the hierarchy. +""" + +from __future__ import annotations + +from textual.widgets import Static + + +class Breadcrumb(Static): + """Breadcrumb navigation widget. + + Displays a path-like navigation trail like: + Projects > experiment-001 > run-001 + """ + + DEFAULT_CSS = """ + Breadcrumb { + width: 100%; + height: 1; + padding: 0 1; + background: $surface; + color: $text-muted; + } + """ + + def __init__(self, items: list[str], separator: str = " > ") -> None: + """Initialize the breadcrumb. + + Args: + items: List of breadcrumb items. + separator: Separator string between items. + """ + self._items = items + self._separator = separator + super().__init__(self._format_breadcrumb()) + + def _format_breadcrumb(self) -> str: + """Format the breadcrumb text. + + Returns: + Formatted breadcrumb string. + """ + if not self._items: + return "" + + parts = [] + for i, item in enumerate(self._items): + if i == len(self._items) - 1: + parts.append(f"[bold]{item}[/bold]") + else: + parts.append(f"[dim]{item}[/dim]") + + return self._separator.join(parts) + + def update_items(self, items: list[str]) -> None: + """Update breadcrumb items. + + Args: + items: New list of breadcrumb items. + """ + self._items = items + self.update(self._format_breadcrumb()) diff --git a/src/aspara/tui/widgets/metrics_grid.py b/src/aspara/tui/widgets/metrics_grid.py new file mode 100644 index 0000000000000000000000000000000000000000..9cc874b94c66556ad0af3b1808aa0ad80335fa0f --- /dev/null +++ b/src/aspara/tui/widgets/metrics_grid.py @@ -0,0 +1,289 @@ +""" +Metrics Grid Widget + +A widget that displays multiple metrics in a grid layout using individual PlotextPlot widgets. +This solves the xticks/xlabel truncation issue that occurs with ItemGrid. +""" + +from __future__ import annotations + +from textual import on +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.dom import DOMNode +from textual.events import Click, Resize +from textual.message import Message +from textual.reactive import reactive +from textual.timer import Timer +from textual.widget import Widget +from textual.widgets import Static + +from aspara.tui.app import CHART_LINE_COLOR + +try: + from textual_plotext import PlotextPlot + + HAS_PLOTEXT = True +except ImportError: + HAS_PLOTEXT = False + + +class _ChartCell(Widget): + """A single chart cell in the metrics grid.""" + + can_focus = True + + class Selected(Message): + """Message sent when the chart cell is selected.""" + + def __init__(self, metric_name: str) -> None: + """Initialize the Selected message. + + Args: + metric_name: The name of the selected metric. + """ + self.metric_name = metric_name + super().__init__() + + def __init__( + self, + metric_name: str, + steps: list[int], + values: list[float], + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + super().__init__(name=name, id=id, classes=classes) + self._metric_name = metric_name + self._steps = steps + self._values = values + self._plot: PlotextPlot | None = None + + @property + def metric_name(self) -> str: + """Get the metric name.""" + return self._metric_name + + def compose(self) -> ComposeResult: + """Compose the widget layout.""" + if HAS_PLOTEXT: + self._plot = PlotextPlot() + yield self._plot + else: + yield Static(f"{self._metric_name}: No plotext") + + def on_mount(self) -> None: + """Handle mount event - render the chart.""" + self._render_chart() + + def _render_chart(self) -> None: + """Render the chart with current data.""" + if not HAS_PLOTEXT or self._plot is None: + return + + self._plot.plt.clear_figure() + + if self._steps and self._values: + display_steps, display_values = self._downsample_for_display() + + current_value = self._values[-1] + if isinstance(current_value, float): + value_str = f"{current_value:.4g}" + else: + value_str = str(current_value) + + display_name = self._metric_name.lstrip("_") + self._plot.plt.title(f"{display_name} = {value_str}") + self._plot.plt.plot(display_steps, display_values, color=CHART_LINE_COLOR) + + # Set xticks to show step range + if len(display_steps) >= 2: + min_step = display_steps[0] + max_step = display_steps[-1] + self._plot.plt.xticks([min_step, max_step]) + else: + display_name = self._metric_name.lstrip("_") + self._plot.plt.title(f"{display_name} (no data)") + + self._plot.refresh() + + def _downsample_for_display(self) -> tuple[list[int], list[float]]: + """Downsample data for mini chart display. + + Returns: + Tuple of (steps, values) downsampled if necessary. + """ + max_points = 50 + if len(self._steps) <= max_points: + return self._steps, self._values + + try: + import numpy as np + from lttb import downsample + + data = np.array(list(zip(self._steps, self._values, strict=True))) + downsampled = downsample(data, max_points) + return ( + [int(d[0]) for d in downsampled], + [float(d[1]) for d in downsampled], + ) + except ImportError: + step = len(self._steps) // max_points + return self._steps[::step], self._values[::step] + + def key_enter(self) -> None: + """Handle Enter key press.""" + self.post_message(self.Selected(self._metric_name)) + + +class MetricsGridWidget(Widget): + """A widget that displays multiple metrics in a responsive grid. + + Uses individual PlotextPlot widgets arranged in Horizontal containers + to ensure proper xticks display. + + Attributes: + MIN_CHART_WIDTH: Minimum column width in characters (for xticks visibility). + CHART_HEIGHT: Height per chart in terminal lines. + RESIZE_DEBOUNCE_DELAY: Delay in seconds before processing resize events. + """ + + MIN_CHART_WIDTH = 40 + CHART_HEIGHT = 14 + RESIZE_DEBOUNCE_DELAY = 0.15 + + _cols = reactive(1) + + class MetricSelected(Message): + """Message sent when a metric chart is clicked.""" + + def __init__(self, metric_name: str) -> None: + """Initialize the MetricSelected message. + + Args: + metric_name: The name of the selected metric. + """ + self.metric_name = metric_name + super().__init__() + + def __init__( + self, + metrics: list[tuple[str, list[int], list[float]]], + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + """Initialize the MetricsGridWidget. + + Args: + metrics: List of tuples (metric_name, steps, values). + name: Widget name. + id: Widget ID. + classes: CSS classes. + """ + super().__init__(name=name, id=id, classes=classes) + self._metrics = metrics + self._resize_timer: Timer | None = None + + def compose(self) -> ComposeResult: + """Compose the widget layout.""" + if not self._metrics: + yield Static("No metrics available") + return + + yield Vertical(id="metrics-rows") + + def on_mount(self) -> None: + """Handle mount event - build the grid.""" + self._rebuild_grid() + + def on_resize(self, event: Resize) -> None: + """Handle resize event - recalculate grid dimensions with debounce.""" + if self._resize_timer is not None: + self._resize_timer.stop() + self._resize_timer = self.set_timer(self.RESIZE_DEBOUNCE_DELAY, self._handle_debounced_resize) + + def _handle_debounced_resize(self) -> None: + """Handle debounced resize - rebuild grid if column count changed.""" + self._resize_timer = None + new_cols = self._calculate_cols() + if new_cols != self._cols: + self._cols = new_cols + self._rebuild_grid() + + def _calculate_cols(self) -> int: + """Calculate optimal column count based on widget width. + + Returns: + Number of columns for the grid layout. + """ + width = self.size.width if self.size.width > 0 else 80 + cols = max(1, width // self.MIN_CHART_WIDTH) + cols = min(cols, len(self._metrics)) + return cols + + def _rebuild_grid(self) -> None: + """Rebuild the grid with current column count.""" + if not self._metrics: + return + + try: + container = self.query_one("#metrics-rows", Vertical) + except Exception: + return + + # Remove existing rows + container.remove_children() + + cols = self._calculate_cols() + rows_count = (len(self._metrics) + cols - 1) // cols + + # Update container height + self.styles.height = rows_count * self.CHART_HEIGHT + 2 + + # Create rows with cells + for row_idx in range(rows_count): + start_idx = row_idx * cols + end_idx = min(start_idx + cols, len(self._metrics)) + + # Create cells for this row + cells = [] + for i in range(start_idx, end_idx): + name, steps, values = self._metrics[i] + cell = _ChartCell(name, steps, values, id=f"chart-cell-{i}", classes="chart-cell") + cells.append(cell) + + # Create row with cells as children + row = Horizontal(*cells, classes="metrics-row") + container.mount(row) + + @staticmethod + def _get_metric_name_from_cell(cell: _ChartCell) -> str: + """Get metric name from a chart cell.""" + return cell.metric_name + + def on_click(self, event: Click) -> None: + """Handle click event - determine which metric was clicked. + + Args: + event: The click event. + """ + # Find clicked chart cell by traversing the widget tree + target: DOMNode | None = event.widget + while target is not None and target is not self: + if isinstance(target, _ChartCell): + self.post_message(self.MetricSelected(target.metric_name)) + return + target = target.parent + + @on(_ChartCell.Selected) + def _on_cell_selected(self, event: _ChartCell.Selected) -> None: + """Handle cell selection and propagate as MetricSelected. + + Args: + event: The cell selected event. + """ + self.post_message(self.MetricSelected(event.metric_name)) diff --git a/src/aspara/tui/widgets/mini_chart.py b/src/aspara/tui/widgets/mini_chart.py new file mode 100644 index 0000000000000000000000000000000000000000..7ae612a0711ccad711ac62a4d58a96c9f8a20b26 --- /dev/null +++ b/src/aspara/tui/widgets/mini_chart.py @@ -0,0 +1,154 @@ +""" +Mini Chart Widget + +A small chart widget for displaying metric data in a grid layout. +""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Static + +from aspara.tui.app import CHART_LINE_COLOR + +try: + from textual_plotext import PlotextPlot + + HAS_PLOTEXT = True +except ImportError: + HAS_PLOTEXT = False + + +class MiniChartWidget(Widget): + """A small chart widget for displaying a single metric. + + Attributes: + metric_name: Name of the metric being displayed. + steps: List of step values (x-axis). + values: List of metric values (y-axis). + """ + + can_focus = True + + class Selected(Message): + """Message sent when the chart is selected.""" + + def __init__(self, metric_name: str) -> None: + """Initialize the Selected message. + + Args: + metric_name: The name of the selected metric. + """ + self.metric_name = metric_name + super().__init__() + + def __init__( + self, + metric_name: str, + steps: list[int], + values: list[float], + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + """Initialize the MiniChartWidget. + + Args: + metric_name: Name of the metric. + steps: List of step values. + values: List of metric values. + name: Widget name. + id: Widget ID. + classes: CSS classes. + """ + super().__init__(name=name, id=id, classes=classes) + self._metric_name = metric_name + self._steps = steps + self._values = values + self._plot: PlotextPlot | None = None + + @property + def metric_name(self) -> str: + """Get the metric name.""" + return self._metric_name + + def compose(self) -> ComposeResult: + """Compose the widget layout.""" + if HAS_PLOTEXT: + self._plot = PlotextPlot() + yield self._plot + else: + yield Static(f"{self._metric_name}: No plotext") + + def on_mount(self) -> None: + """Handle mount event - render the chart.""" + self._render_chart() + + def _render_chart(self) -> None: + """Render the chart with current data.""" + if not HAS_PLOTEXT or self._plot is None: + return + + self._plot.plt.clear_figure() + + if self._steps and self._values: + display_steps, display_values = self._downsample_for_display() + + current_value = self._values[-1] + if isinstance(current_value, float): + value_str = f"{current_value:.4g}" + else: + value_str = str(current_value) + + # Include step range in title (always visible even in small charts) + if len(display_steps) >= 2: + step_info = f"step {display_steps[0]}-{display_steps[-1]}" + else: + step_info = f"step {display_steps[0]}" if display_steps else "" + title = f"{self._metric_name} ({step_info}) = {value_str}" + self._plot.plt.title(title) + + self._plot.plt.plot(display_steps, display_values, color=CHART_LINE_COLOR) + else: + self._plot.plt.title(f"{self._metric_name} (no data)") + + self._plot.refresh() + + def _downsample_for_display(self) -> tuple[list[int], list[float]]: + """Downsample data for mini chart display. + + Returns: + Tuple of (steps, values) downsampled if necessary. + """ + max_points = 50 + if len(self._steps) <= max_points: + return self._steps, self._values + + try: + import numpy as np + from lttb import downsample + + data = np.array(list(zip(self._steps, self._values, strict=True))) + downsampled = downsample(data, max_points) + return ( + [int(d[0]) for d in downsampled], + [float(d[1]) for d in downsampled], + ) + except ImportError: + step = len(self._steps) // max_points + return self._steps[::step], self._values[::step] + + def on_click(self) -> None: + """Handle click event.""" + self.post_message(self.Selected(self._metric_name)) + + def action_select(self) -> None: + """Action to select this chart (triggered by Enter key).""" + self.post_message(self.Selected(self._metric_name)) + + def key_enter(self) -> None: + """Handle Enter key press.""" + self.post_message(self.Selected(self._metric_name)) diff --git a/src/aspara/utils/__init__.py b/src/aspara/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..70af784421fdd0bcd62c5b146e1e7427c28ce59b --- /dev/null +++ b/src/aspara/utils/__init__.py @@ -0,0 +1,25 @@ +"""Utility modules for aspara.""" + +from aspara.utils.file import atomic_write_json, atomic_write_text, datasync, secure_open_append +from aspara.utils.metadata import update_project_metadata_tags +from aspara.utils.timestamp import parse_to_datetime, parse_to_ms +from aspara.utils.validators import ( + validate_name, + validate_project_name, + validate_run_name, + validate_safe_path, +) + +__all__ = [ + "atomic_write_json", + "atomic_write_text", + "datasync", + "parse_to_datetime", + "parse_to_ms", + "secure_open_append", + "update_project_metadata_tags", + "validate_name", + "validate_project_name", + "validate_run_name", + "validate_safe_path", +] diff --git a/src/aspara/utils/file.py b/src/aspara/utils/file.py new file mode 100644 index 0000000000000000000000000000000000000000..9b7a577ba8e25e1bcd13d8cecc9e8f12d367ba33 --- /dev/null +++ b/src/aspara/utils/file.py @@ -0,0 +1,141 @@ +"""File operation utilities for aspara.""" + +import contextlib +import json +import os +import platform +import stat +import tempfile +from collections.abc import Callable, Generator +from pathlib import Path +from typing import IO, Any + + +def datasync(fd: int) -> None: + """Sync file data to disk. + + Uses fdatasync on Linux, fsync on macOS/other platforms. + + Args: + fd: File descriptor to sync + """ + if hasattr(os, "fdatasync") and platform.system() != "Darwin": + os.fdatasync(fd) + else: + os.fsync(fd) + + +@contextlib.contextmanager +def secure_open_append(path: str | Path) -> Generator[IO[str], None, None]: + """Securely open a file for appending with restricted permissions. + + Creates the file with 0o600 permissions if it doesn't exist. + Uses os.open() with O_CREAT to atomically create with correct permissions. + + Args: + path: File path to open + + Yields: + File object opened for appending + + Examples: + with secure_open_append("/path/to/file.txt") as f: + f.write("data\\n") + """ + file_path = Path(path) + + # Create parent directory if needed + file_path.parent.mkdir(parents=True, exist_ok=True) + + # Open with O_CREAT to create with correct permissions atomically + # Mode 0o600 = read/write for owner only + flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT + fd = os.open(str(file_path), flags, 0o600) + + fd_to_close: int | None = fd + try: + # Convert fd to a file object + with os.fdopen(fd, "a") as f: + fd_to_close = None # fd is now owned by the file object + yield f + finally: + # Close fd if fdopen failed + if fd_to_close is not None: + os.close(fd_to_close) + + +def atomic_write_text( + path: str | Path, + write_fn: Callable[[IO[str]], None], + *, + suffix: str = ".tmp", +) -> None: + """Atomically write text content to a file. + + Writes to a secure temporary file in the same directory, fsyncs it, then + atomically renames it over the target. This avoids the partial-write / + truncate-then-write races that lose data on crash or disk-full. + + Args: + path: Target file path + write_fn: Callback that receives an open text file object and writes + the desired content to it. Must not close the file object. + suffix: Suffix for the temporary file. + """ + target_path = Path(path) + target_dir = target_path.parent + + # Ensure target directory exists + target_dir.mkdir(parents=True, exist_ok=True) + + # Use tempfile.mkstemp for secure temp file creation + # - Creates file with O_EXCL flag (prevents symlink attacks) + # - Creates in same directory as target (allows atomic rename) + tmp_fd = None + tmp_path = None + try: + tmp_fd, tmp_name = tempfile.mkstemp( + suffix=suffix, + prefix=".tmp_", + dir=str(target_dir), + ) + tmp_path = Path(tmp_name) + + # Write data to temp file + with os.fdopen(tmp_fd, "w", encoding="utf-8") as f: + tmp_fd = None # fd is now owned by the file object + write_fn(f) + f.flush() + datasync(f.fileno()) + + # Set appropriate permissions (readable by owner only) + os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) + + # Atomically replace target file + os.replace(tmp_path, target_path) + tmp_path = None # Successfully renamed, don't clean up + + finally: + # Clean up temp file if rename failed + if tmp_fd is not None: + os.close(tmp_fd) + if tmp_path is not None and tmp_path.exists(): + with contextlib.suppress(OSError): + tmp_path.unlink() + + +def atomic_write_json(path: str | Path, data: dict[str, Any]) -> None: + """Atomically write JSON data to a file. + + Writes to a secure temporary file first, then renames to avoid partial writes. + Uses tempfile.NamedTemporaryFile to prevent symlink attacks and race conditions. + + Args: + path: Target file path + data: Dictionary to write as JSON + """ + + def _write(f: IO[str]) -> None: + json.dump(data, f, ensure_ascii=False, indent=2) + + atomic_write_text(path, _write, suffix=".json") diff --git a/src/aspara/utils/metadata.py b/src/aspara/utils/metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..103b1cea1c66b7b371563f0e2050a562983ef11d --- /dev/null +++ b/src/aspara/utils/metadata.py @@ -0,0 +1,44 @@ +"""Metadata utilities for project-level operations.""" + +from __future__ import annotations + +from pathlib import Path + + +def update_project_metadata_tags(base_dir: str | Path, project_name: str, new_tags: list[str] | None) -> None: + """Append tags to project-level metadata file. + + This writes to {base_dir}/{project_name}/metadata.json in the standard + project metadata format (notes, tags, created_at, updated_at) used by + the dashboard/catalog layer. + """ + if not new_tags: + return + + # Use ProjectCatalog metadata API so that project-level metadata.json + # is managed through the shared catalog/metadata_utils helpers. + from aspara.catalog import ProjectCatalog + + base_dir_path = Path(base_dir) + catalog = ProjectCatalog(base_dir_path) + + existing_metadata = catalog.get_metadata(project_name) + + # Merge tags and remove duplicates while preserving order + existing_tags = [t for t in (existing_metadata.get("tags") or []) if isinstance(t, str)] + added_tags = [t for t in new_tags if isinstance(t, str)] + seen: set[str] = set() + merged: list[str] = [] + for tag in existing_tags + added_tags: + if tag not in seen: + seen.add(tag) + merged.append(tag) + + # Only update tags; notes and timestamps are handled by metadata_utils + try: + catalog.update_metadata(project_name, {"tags": merged}) + except Exception as e: + # Log metadata write failures but don't impact run tracking + from aspara.logger import logger + + logger.warning(f"Failed to update project metadata tags for '{project_name}': {e}") diff --git a/src/aspara/utils/timestamp.py b/src/aspara/utils/timestamp.py new file mode 100644 index 0000000000000000000000000000000000000000..0190b0f947065fb9a01e2ed20fc646d66cc56243 --- /dev/null +++ b/src/aspara/utils/timestamp.py @@ -0,0 +1,93 @@ +"""Timestamp parsing and normalization utilities. + +This module provides unified timestamp parsing functions for the aspara library. +All functions raise ValueError for invalid input and do not accept None. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + + +def now_ms() -> int: + """Return the current UNIX timestamp in milliseconds. + + This is the single source of truth for "current time as ms" in the codebase. + """ + return int(datetime.now(timezone.utc).timestamp() * 1000) + + +def parse_to_datetime(ts_value: str | int | float | datetime) -> datetime: + """Parse various timestamp formats to UTC datetime. + + Args: + ts_value: Timestamp in one of: + - datetime: returned as-is (UTC ensured) + - int/float: Unix timestamp in milliseconds + - str: ISO 8601 format string + + Returns: + datetime object in UTC timezone. + + Raises: + ValueError: If input is None, empty string, or invalid format. + """ + if ts_value is None: + raise ValueError("Timestamp cannot be None") + + if isinstance(ts_value, datetime): + # Ensure timezone-aware + if ts_value.tzinfo is None: + return ts_value.replace(tzinfo=timezone.utc) + return ts_value + + if isinstance(ts_value, (int, float)): + return datetime.fromtimestamp(ts_value / 1000, tz=timezone.utc) + + if isinstance(ts_value, str): + ts_str = ts_value.strip() + if not ts_str: + raise ValueError("Timestamp string cannot be empty") + + # Handle ISO 8601 format with 'Z' suffix + if ts_str.endswith("Z"): + ts_str = ts_str[:-1] + "+00:00" + + try: + parsed = datetime.fromisoformat(ts_str) + except ValueError as exc: + raise ValueError("Invalid timestamp format. Expected ISO 8601 string or UNIX milliseconds.") from exc + + # Ensure timezone-aware + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + raise ValueError(f"Unsupported timestamp type: {type(ts_value)}. Expected datetime, int, float, or ISO 8601 string.") + + +def parse_to_ms(ts_value: str | int | float | datetime) -> int: + """Normalize timestamp to UNIX milliseconds. + + Args: + ts_value: Timestamp in one of: + - datetime: converted to UNIX milliseconds + - int/float: treated as UNIX time in milliseconds + - str: parsed as ISO 8601 format string + + Returns: + int: UNIX timestamp in milliseconds. + + Raises: + ValueError: If input is None, empty string, or invalid format. + """ + if ts_value is None: + raise ValueError("Timestamp cannot be None") + + # Numeric -> treat as UNIX ms + if isinstance(ts_value, (int, float)): + return int(ts_value) + + # Other types -> parse to datetime first, then convert to ms + dt = parse_to_datetime(ts_value) + return int(dt.timestamp() * 1000) diff --git a/src/aspara/utils/validators.py b/src/aspara/utils/validators.py new file mode 100644 index 0000000000000000000000000000000000000000..f9bc3d0e7f993b52dfd925de8e227e0a2ff37861 --- /dev/null +++ b/src/aspara/utils/validators.py @@ -0,0 +1,149 @@ +"""Security validators for Aspara. + +This module provides common validation functions to prevent security vulnerabilities +like path traversal attacks. +""" + +import os +import re +from pathlib import Path + +__all__ = ["validate_safe_path", "validate_name", "validate_project_name", "validate_run_name", "validate_artifact_name"] + +# Security validation pattern for project/run names +# Only allow alphanumeric characters, underscores, and hyphens +_SAFE_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$") + +# More permissive pattern for artifact/file names +# Allows alphanumeric, underscores, hyphens, and dots (for file extensions) +# Does NOT allow: slashes, backslashes, null bytes, or special path components +_SAFE_FILENAME_PATTERN = re.compile(r"^[a-zA-Z0-9_.\-]+$") + + +def validate_safe_path(path: Path, base_dir: Path) -> None: + """Validate that the resolved path is within the base directory. + + This function prevents path traversal attacks by ensuring that the resolved + absolute path stays within the base directory boundaries. Symlinks are + handled implicitly by ``Path.resolve()``, which follows them before the + boundary check. + + Args: + path: Path to validate + base_dir: Base directory that should contain the path + + Raises: + ValueError: If path is outside base_dir or path resolution fails + + Examples: + >>> base = Path("/data") + >>> validate_safe_path(Path("/data/project/run"), base) # OK + >>> validate_safe_path(Path("/data/../etc/passwd"), base) # Raises ValueError + """ + try: + # Resolve both paths to absolute paths; resolve() follows symlinks, + # so a symlink that escapes base_dir is rejected by the check below. + resolved_path = path.resolve() + resolved_base = base_dir.resolve() + + # Check if the resolved path is within the base directory + # Use os.path.commonpath for more robust comparison + if not str(resolved_path).startswith(str(resolved_base) + os.sep) and resolved_path != resolved_base: + raise ValueError(f"Path {path} is outside base directory {base_dir}") + except (ValueError, OSError) as e: + # If path resolution fails or validation fails, raise error + if isinstance(e, ValueError) and str(e).startswith("Path"): + raise + raise ValueError(f"Invalid path: {path}") from e + + +def validate_name(name: str, name_type: str = "name") -> None: + """Validate a name to prevent path traversal attacks. + + This function ensures that names (project names, run names, etc.) only contain + safe characters to prevent directory traversal and other security vulnerabilities. + + Args: + name: Name from user input + name_type: Type of name (for error messages), e.g., "project", "run" + + Raises: + ValueError: If name is empty or contains invalid characters + + Examples: + >>> validate_name("my_project", "project") # OK + >>> validate_name("../etc/passwd", "project") # Raises ValueError + >>> validate_name("", "project") # Raises ValueError + """ + if not name or not _SAFE_NAME_PATTERN.match(name): + raise ValueError(f"Invalid {name_type}. Only alphanumeric characters, underscores, and hyphens are allowed.") + + +def validate_project_name(project: str) -> None: + """Validate project name to prevent path traversal attacks. + + Args: + project: Project name from user input + + Raises: + ValueError: If project name contains invalid characters + + Examples: + >>> validate_project_name("my_project") # OK + >>> validate_project_name("../etc") # Raises ValueError + """ + validate_name(project, "project name") + + +def validate_run_name(run: str) -> None: + """Validate run name to prevent path traversal attacks. + + Args: + run: Run name from user input + + Raises: + ValueError: If run name contains invalid characters + + Examples: + >>> validate_run_name("experiment_001") # OK + >>> validate_run_name("../../passwd") # Raises ValueError + """ + validate_name(run, "run name") + + +def validate_artifact_name(name: str) -> None: + """Validate an artifact/file name. + + Artifact names can contain alphanumeric characters, underscores, hyphens, and dots + (for file extensions). This is more permissive than project/run names but still + prevents path traversal attacks. + + Args: + name: Artifact name to validate + + Raises: + ValueError: If name is invalid or could enable path traversal + + Examples: + >>> validate_artifact_name("model.pt") # OK + >>> validate_artifact_name("test_file.txt") # OK + >>> validate_artifact_name("../etc/passwd") # Raises ValueError + >>> validate_artifact_name("..hidden") # Raises ValueError (starts with ..) + """ + if not name: + raise ValueError("Invalid artifact name: cannot be empty") + + if len(name) > 255: + raise ValueError("Invalid artifact name: exceeds maximum length of 255 characters") + + # Block path traversal attempts + if name in (".", ".."): + raise ValueError(f"Invalid artifact name: cannot be '{name}'") + + # Block names starting with ".." (potential path traversal) + if name.startswith(".."): + raise ValueError("Invalid artifact name: cannot start with '..'") + + # Validate characters + if not _SAFE_FILENAME_PATTERN.match(name): + raise ValueError(f"Invalid artifact name: '{name}'. Names can only contain alphanumeric characters, underscores, hyphens, and dots.") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..4738ea30c0e2fdea3c3b1ab24d51c18710bce8e2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1842 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "aspara" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "polars" }, + { name = "pydantic" }, +] + +[package.optional-dependencies] +all = [ + { name = "aiofiles" }, + { name = "fastapi" }, + { name = "idna" }, + { name = "lttb" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "msgpack" }, + { name = "numpy" }, + { name = "pystache" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "textual" }, + { name = "textual-plotext" }, + { name = "urllib3" }, + { name = "uvicorn" }, + { name = "watchfiles" }, +] +dashboard = [ + { name = "aiofiles" }, + { name = "fastapi" }, + { name = "lttb" }, + { name = "msgpack" }, + { name = "numpy" }, + { name = "pystache" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] +remote = [ + { name = "idna" }, + { name = "requests" }, + { name = "urllib3" }, +] +tracker = [ + { name = "fastapi" }, + { name = "python-multipart" }, + { name = "starlette" }, + { name = "uvicorn" }, +] +tui = [ + { name = "textual" }, + { name = "textual-plotext" }, +] + +[package.dev-dependencies] +dev = [ + { name = "aspara", extra = ["all"] }, + { name = "httpx" }, + { name = "playwright" }, + { name = "py-spy" }, + { name = "pyrefly" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, + { name = "types-requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiofiles", marker = "extra == 'dashboard'", specifier = ">=23.2.0" }, + { name = "aspara", extras = ["dashboard"], marker = "extra == 'all'" }, + { name = "aspara", extras = ["docs"], marker = "extra == 'all'" }, + { name = "aspara", extras = ["remote"], marker = "extra == 'all'" }, + { name = "aspara", extras = ["tracker"], marker = "extra == 'all'" }, + { name = "aspara", extras = ["tui"], marker = "extra == 'all'" }, + { name = "fastapi", marker = "extra == 'dashboard'", specifier = ">=0.115.12" }, + { name = "fastapi", marker = "extra == 'tracker'", specifier = ">=0.115.12" }, + { name = "idna", marker = "extra == 'remote'", specifier = ">=3.15" }, + { name = "lttb", marker = "extra == 'dashboard'", specifier = ">=0.3.2" }, + { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6.0" }, + { name = "mkdocs-autorefs", marker = "extra == 'docs'", specifier = ">=0.5.0" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.6.0" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.24.0" }, + { name = "msgpack", marker = "extra == 'dashboard'", specifier = ">=1.2.1" }, + { name = "numpy", marker = "extra == 'dashboard'", specifier = ">=1.24.0" }, + { name = "polars", specifier = ">=1.37.1" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pystache", marker = "extra == 'dashboard'", specifier = ">=0.6.8" }, + { name = "python-multipart", marker = "extra == 'tracker'", specifier = ">=0.0.31" }, + { name = "requests", marker = "extra == 'remote'", specifier = ">=2.31.0" }, + { name = "sse-starlette", marker = "extra == 'dashboard'", specifier = ">=1.8.0" }, + { name = "starlette", marker = "extra == 'dashboard'", specifier = ">=1.3.1" }, + { name = "starlette", marker = "extra == 'tracker'", specifier = ">=1.3.1" }, + { name = "textual", marker = "extra == 'tui'", specifier = ">=0.47.0" }, + { name = "textual-plotext", marker = "extra == 'tui'", specifier = ">=0.2.0" }, + { name = "urllib3", marker = "extra == 'remote'", specifier = ">=2.7.0" }, + { name = "uvicorn", marker = "extra == 'dashboard'", specifier = ">=0.27.0" }, + { name = "uvicorn", marker = "extra == 'tracker'", specifier = ">=0.27.0" }, + { name = "watchfiles", marker = "extra == 'dashboard'", specifier = ">=1.1.1" }, +] +provides-extras = ["tracker", "dashboard", "remote", "tui", "all", "docs"] + +[package.metadata.requires-dev] +dev = [ + { name = "aspara", extras = ["all"] }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "playwright", specifier = ">=1.52.0" }, + { name = "py-spy", specifier = ">=0.4.1" }, + { name = "pyrefly", specifier = ">=0.46.1" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.14.10" }, + { name = "ty", specifier = ">=0.0.12" }, + { name = "types-requests", specifier = ">=2.31.0" }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "backrefs" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, + { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" }, + { url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" }, + { url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" }, + { url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" }, + { url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, + { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, + { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.138.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/c9/5e8defe249899c0dc900643695fc07829a67fc88b4ff2cdb03fcbdbf5a4b/fastapi-0.138.1.tar.gz", hash = "sha256:96e3702dce09ee0dce48856135620d3d865ca684a79fe7513fd7b13a12f82862", size = 419646, upload-time = "2026-06-25T15:40:42.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/a9/69a6924f645eb4dd8cd625bf255b3625990eb3e14e073438a53c405dcd3e/fastapi-0.138.1-py3-none-any.whl", hash = "sha256:b994cae7ba8b82c976a728b544244de31333fa5f7d261f9a1dffe526444cae23", size = 129182, upload-time = "2026-06-25T15:40:40.771Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/6a/33d1702184d94106d3cdd7bfb788e19723206fce152e303473ca3b946c7b/greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d", size = 273658, upload-time = "2025-12-04T14:23:37.494Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b7/2b5805bbf1907c26e434f4e448cd8b696a0b71725204fa21a211ff0c04a7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb", size = 574810, upload-time = "2025-12-04T14:50:04.154Z" }, + { url = "https://files.pythonhosted.org/packages/94/38/343242ec12eddf3d8458c73f555c084359883d4ddc674240d9e61ec51fd6/greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd", size = 586248, upload-time = "2025-12-04T14:57:39.35Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/0ae86792fb212e4384041e0ef8e7bc66f59a54912ce407d26a966ed2914d/greenlet-3.3.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b299a0cb979f5d7197442dccc3aee67fce53500cd88951b7e6c35575701c980b", size = 597403, upload-time = "2025-12-04T15:07:10.831Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a8/15d0aa26c0036a15d2659175af00954aaaa5d0d66ba538345bd88013b4d7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5", size = 586910, upload-time = "2025-12-04T14:25:59.705Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9b/68d5e3b7ccaba3907e5532cf8b9bf16f9ef5056a008f195a367db0ff32db/greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9", size = 1547206, upload-time = "2025-12-04T15:04:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/66/bd/e3086ccedc61e49f91e2cfb5ffad9d8d62e5dc85e512a6200f096875b60c/greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d", size = 1613359, upload-time = "2025-12-04T14:27:26.548Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/d4e73f5dfa888364bbf02efa85616c6714ae7c631c201349782e5b428925/greenlet-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b49e7ed51876b459bd645d83db257f0180e345d3f768a35a85437a24d5a49082", size = 300740, upload-time = "2025-12-04T14:47:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" }, + { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, + { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, + { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, + { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, +] + +[[package]] +name = "griffe" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + +[[package]] +name = "lttb" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/af/422282fcfe21179a4e25a03bea607d51f426957956b18fbe97b7bda8ba13/lttb-0.3.2.tar.gz", hash = "sha256:b7f280d3ad71a68497f75eee3d03f1bcaccac0d56c430b7afa562682bf6c69c0", size = 106753, upload-time = "2024-09-06T19:23:17.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/83/146c427d76d647a474da827c7d896b8114fb17db93b1393939cecde45af3/lttb-0.3.2-py3-none-any.whl", hash = "sha256:10fddd96bc4b6084ce9146045aeed2016176b78057d8803424a6740af102ef50", size = 5685, upload-time = "2024-09-06T19:23:13.889Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { 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.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + +[[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 = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/ec/680e3bc7c88704d3fb9c658a517ec10f2f2aed3b9340136978675e581688/mkdocstrings-1.0.1.tar.gz", hash = "sha256:caa7d311c85ac0a0674831725ecfdeee4348e3b8a2c91ab193ee319a41dbeb3d", size = 100794, upload-time = "2026-01-19T11:36:24.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/f9/ecd3e5cf258d63eddc13e354bd090df3aa458b64be50d737d52a8ad9df22/mkdocstrings-1.0.1-py3-none-any.whl", hash = "sha256:10deb908e310e6d427a5b8f69026361dac06b77de860f46043043e26f121db02", size = 35245, upload-time = "2026-01-19T11:36:23.067Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/75/d30af27a2906f00eb90143470272376d728521997800f5dce5b340ba35bc/mkdocstrings_python-2.0.1.tar.gz", hash = "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732", size = 199345, upload-time = "2025-12-03T14:26:11.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/06/c5f8deba7d2cbdfa7967a716ae801aa9ca5f734b8f54fd473ef77a088dbe/mkdocstrings_python-2.0.1-py3-none-any.whl", hash = "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", size = 105055, upload-time = "2025-12-03T14:26:10.184Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" }, + { url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" }, + { url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, + { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468, upload-time = "2024-02-05T23:48:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411, upload-time = "2024-02-05T23:48:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016, upload-time = "2024-02-05T23:48:54.098Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889, upload-time = "2024-02-05T23:49:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746, upload-time = "2024-02-05T23:49:51.983Z" }, + { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620, upload-time = "2024-02-05T23:50:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659, upload-time = "2024-02-05T23:50:35.834Z" }, + { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905, upload-time = "2024-02-05T23:51:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554, upload-time = "2024-02-05T23:51:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127, upload-time = "2024-02-05T23:52:15.314Z" }, + { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994, upload-time = "2024-02-05T23:52:47.569Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005, upload-time = "2024-02-05T23:53:15.637Z" }, + { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297, upload-time = "2024-02-05T23:53:42.16Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567, upload-time = "2024-02-05T23:54:11.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812, upload-time = "2024-02-05T23:54:26.453Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913, upload-time = "2024-02-05T23:54:53.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "playwright" +version = "1.57.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/b6/e17543cea8290ae4dced10be21d5a43c360096aa2cce0aa7039e60c50df3/playwright-1.57.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9351c1ac3dfd9b3820fe7fc4340d96c0d3736bb68097b9b7a69bd45d25e9370c", size = 41985039, upload-time = "2025-12-09T08:06:18.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4a9d65027bce48eeba842408bcc1421502dfd7e41e28d207e94260fa93ca67e", size = 40775575, upload-time = "2025-12-09T08:06:22.105Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/5563850322a663956c927eefcf1457d12917e8f118c214410e815f2147d1/playwright-1.57.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:99104771abc4eafee48f47dac2369e0015516dc1ce8c409807d2dd440828b9a4", size = 41985042, upload-time = "2025-12-09T08:06:25.357Z" }, + { url = "https://files.pythonhosted.org/packages/56/61/3a803cb5ae0321715bfd5247ea871d25b32c8f372aeb70550a90c5f586df/playwright-1.57.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:284ed5a706b7c389a06caa431b2f0ba9ac4130113c3a779767dda758c2497bb1", size = 45975252, upload-time = "2025-12-09T08:06:29.186Z" }, + { url = "https://files.pythonhosted.org/packages/83/d7/b72eb59dfbea0013a7f9731878df8c670f5f35318cedb010c8a30292c118/playwright-1.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a1bae6c0a07839cdeaddbc0756b3b2b85e476c07945f64ece08f1f956a86f1", size = 45706917, upload-time = "2025-12-09T08:06:32.549Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/3fc9ebd7c95ee54ba6a68d5c0bc23e449f7235f4603fc60534a364934c16/playwright-1.57.0-py3-none-win32.whl", hash = "sha256:1dd93b265688da46e91ecb0606d36f777f8eadcf7fbef12f6426b20bf0c9137c", size = 36553860, upload-time = "2025-12-09T08:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/58/d4/dcdfd2a33096aeda6ca0d15584800443dd2be64becca8f315634044b135b/playwright-1.57.0-py3-none-win_amd64.whl", hash = "sha256:6caefb08ed2c6f29d33b8088d05d09376946e49a73be19271c8cd5384b82b14c", size = 36553864, upload-time = "2025-12-09T08:06:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/6a/60/fe31d7e6b8907789dcb0584f88be741ba388413e4fbce35f1eba4e3073de/playwright-1.57.0-py3-none-win_arm64.whl", hash = "sha256:5f065f5a133dbc15e6e7c71e7bc04f258195755b1c32a432b792e28338c8335e", size = 32837940, upload-time = "2025-12-09T08:06:42.268Z" }, +] + +[[package]] +name = "plotext" +version = "5.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967, upload-time = "2024-09-24T15:13:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047, upload-time = "2024-09-24T15:13:36.296Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "polars" +version = "1.37.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/ae/dfebf31b9988c20998140b54d5b521f64ce08879f2c13d9b4d44d7c87e32/polars-1.37.1.tar.gz", hash = "sha256:0309e2a4633e712513401964b4d95452f124ceabf7aec6db50affb9ced4a274e", size = 715572, upload-time = "2026-01-12T23:27:03.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/75/ec73e38812bca7c2240aff481b9ddff20d1ad2f10dee4b3353f5eeaacdab/polars-1.37.1-py3-none-any.whl", hash = "sha256:377fed8939a2f1223c1563cfabdc7b4a3d6ff846efa1f2ddeb8644fafd9b1aff", size = 805749, upload-time = "2026-01-12T23:25:48.595Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.37.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/0b/addabe5e8d28a5a4c9887a08907be7ddc3fce892dc38f37d14b055438a57/polars_runtime_32-1.37.1.tar.gz", hash = "sha256:68779d4a691da20a5eb767d74165a8f80a2bdfbde4b54acf59af43f7fa028d8f", size = 2818945, upload-time = "2026-01-12T23:27:04.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a2/e828ea9f845796de02d923edb790e408ca0b560cd68dbd74bb99a1b3c461/polars_runtime_32-1.37.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0b8d4d73ea9977d3731927740e59d814647c5198bdbe359bcf6a8bfce2e79771", size = 43499912, upload-time = "2026-01-12T23:25:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/81b71b7aa9e3703ee6e4ef1f69a87e40f58ea7c99212bf49a95071e99c8c/polars_runtime_32-1.37.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c682bf83f5f352e5e02f5c16c652c48ca40442f07b236f30662b22217320ce76", size = 39695707, upload-time = "2026-01-12T23:25:54.289Z" }, + { url = "https://files.pythonhosted.org/packages/81/2e/20009d1fde7ee919e24040f5c87cb9d0e4f8e3f109b74ba06bc10c02459c/polars_runtime_32-1.37.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc82b5bbe70ca1a4b764eed1419f6336752d6ba9fc1245388d7f8b12438afa2c", size = 41467034, upload-time = "2026-01-12T23:25:56.925Z" }, + { url = "https://files.pythonhosted.org/packages/eb/21/9b55bea940524324625b1e8fd96233290303eb1bf2c23b54573487bbbc25/polars_runtime_32-1.37.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8362d11ac5193b994c7e9048ffe22ccfb976699cfbf6e128ce0302e06728894", size = 45142711, upload-time = "2026-01-12T23:26:00.817Z" }, + { url = "https://files.pythonhosted.org/packages/8c/25/c5f64461aeccdac6834a89f826d051ccd3b4ce204075e562c87a06ed2619/polars_runtime_32-1.37.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:04f5d5a2f013dca7391b7d8e7672fa6d37573a87f1d45d3dd5f0d9b5565a4b0f", size = 41638564, upload-time = "2026-01-12T23:26:04.186Z" }, + { url = "https://files.pythonhosted.org/packages/35/af/509d3cf6c45e764ccf856beaae26fc34352f16f10f94a7839b1042920a73/polars_runtime_32-1.37.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fbfde7c0ca8209eeaed546e4a32cca1319189aa61c5f0f9a2b4494262bd0c689", size = 44721136, upload-time = "2026-01-12T23:26:07.088Z" }, + { url = "https://files.pythonhosted.org/packages/af/d1/5c0a83a625f72beef59394bebc57d12637997632a4f9d3ab2ffc2cc62bbf/polars_runtime_32-1.37.1-cp310-abi3-win_amd64.whl", hash = "sha256:da3d3642ae944e18dd17109d2a3036cb94ce50e5495c5023c77b1599d4c861bc", size = 44948288, upload-time = "2026-01-12T23:26:10.214Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/061bb702465904b6502f7c9081daee34b09ccbaa4f8c94cf43a2a3b6dd6f/polars_runtime_32-1.37.1-cp310-abi3-win_arm64.whl", hash = "sha256:55f2c4847a8d2e267612f564de7b753a4bde3902eaabe7b436a0a4abf75949a0", size = 41001914, upload-time = "2026-01-12T23:26:12.997Z" }, +] + +[[package]] +name = "py-spy" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/e2/ff811a367028b87e86714945bb9ecb5c1cc69114a8039a67b3a862cef921/py_spy-0.4.1.tar.gz", hash = "sha256:e53aa53daa2e47c2eef97dd2455b47bb3a7e7f962796a86cc3e7dbde8e6f4db4", size = 244726, upload-time = "2025-07-31T19:33:25.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/e3/3a32500d845bdd94f6a2b4ed6244982f42ec2bc64602ea8fcfe900678ae7/py_spy-0.4.1-py2.py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:809094208c6256c8f4ccadd31e9a513fe2429253f48e20066879239ba12cd8cc", size = 3682508, upload-time = "2025-07-31T19:33:13.753Z" }, + { url = "https://files.pythonhosted.org/packages/4f/bf/e4d280e9e0bec71d39fc646654097027d4bbe8e04af18fb68e49afcff404/py_spy-0.4.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:1fb8bf71ab8df95a95cc387deed6552934c50feef2cf6456bc06692a5508fd0c", size = 1796395, upload-time = "2025-07-31T19:33:15.325Z" }, + { url = "https://files.pythonhosted.org/packages/df/79/9ed50bb0a9de63ed023aa2db8b6265b04a7760d98c61eb54def6a5fddb68/py_spy-0.4.1-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee776b9d512a011d1ad3907ed53ae32ce2f3d9ff3e1782236554e22103b5c084", size = 2034938, upload-time = "2025-07-31T19:33:17.194Z" }, + { url = "https://files.pythonhosted.org/packages/53/a5/36862e3eea59f729dfb70ee6f9e14b051d8ddce1aa7e70e0b81d9fe18536/py_spy-0.4.1-py2.py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:532d3525538254d1859b49de1fbe9744df6b8865657c9f0e444bf36ce3f19226", size = 2658968, upload-time = "2025-07-31T19:33:18.916Z" }, + { url = "https://files.pythonhosted.org/packages/08/f8/9ea0b586b065a623f591e5e7961282ec944b5fbbdca33186c7c0296645b3/py_spy-0.4.1-py2.py3-none-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4972c21890b6814017e39ac233c22572c4a61fd874524ebc5ccab0f2237aee0a", size = 2147541, upload-time = "2025-07-31T19:33:20.565Z" }, + { url = "https://files.pythonhosted.org/packages/68/fb/bc7f639aed026bca6e7beb1e33f6951e16b7d315594e7635a4f7d21d63f4/py_spy-0.4.1-py2.py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6a80ec05eb8a6883863a367c6a4d4f2d57de68466f7956b6367d4edd5c61bb29", size = 2763338, upload-time = "2025-07-31T19:33:22.202Z" }, + { url = "https://files.pythonhosted.org/packages/e1/da/fcc9a9fcd4ca946ff402cff20348e838b051d69f50f5d1f5dca4cd3c5eb8/py_spy-0.4.1-py2.py3-none-win_amd64.whl", hash = "sha256:d92e522bd40e9bf7d87c204033ce5bb5c828fca45fa28d970f58d71128069fdc", size = 1818784, upload-time = "2025-07-31T19:33:23.802Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/35/e3814a5b7df295df69d035cfb8aab78b2967cdf11fcfae7faed726b66664/pymdown_extensions-10.20.tar.gz", hash = "sha256:5c73566ab0cf38c6ba084cb7c5ea64a119ae0500cce754ccb682761dfea13a52", size = 852774, upload-time = "2025-12-31T19:59:42.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/10/47caf89cbb52e5bb764696fd52a8c591a2f0e851a93270c05a17f36000b5/pymdown_extensions-10.20-py3-none-any.whl", hash = "sha256:ea9e62add865da80a271d00bfa1c0fa085b20d133fb3fc97afdc88e682f60b2f", size = 268733, upload-time = "2025-12-31T19:59:40.652Z" }, +] + +[[package]] +name = "pyrefly" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/19/8ad522587672c6bb013e284ee8a326136f6511c74784141f3fd550b99aee/pyrefly-0.49.0.tar.gz", hash = "sha256:d4e9a978d55253d2cd24c0354bd4cf087026d07bd374388c2ae12a3bc26f93fc", size = 4822135, upload-time = "2026-01-20T15:13:48.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/47/8c34be1fd5fb3ca74608a71dfece40c4b9d382a8899db8418be9b326ba3f/pyrefly-0.49.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1cd5516ddab7c745e195fe1470629251962498482025bf2a9a9d53d5bde73729", size = 11644108, upload-time = "2026-01-20T15:13:25.358Z" }, + { url = "https://files.pythonhosted.org/packages/57/01/f492c92b4df963dbfda8d8e1cf57477704df8cdecf907568580af60193fe/pyrefly-0.49.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a998a37dc1465a648c03076545080a8bd2a421c67cac27686eca43244e8ac69", size = 11246465, upload-time = "2026-01-20T15:13:27.845Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0b/89da00960e9c43ae7aa5f50886e9f87457137c444e513c00b714fdc6ba1e/pyrefly-0.49.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a96b1452fa61d7db6d5ae6b6297f50ba8c006ba7ce420233ebd33eaf95d04cfd", size = 31723528, upload-time = "2026-01-20T15:13:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/43a2a1a6bc00037879643d7d5257215fea1988dd2ef3168b5fe3cd55dcf0/pyrefly-0.49.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97f1b5fb1be6f8f4868fe40e7ebeed055c8483012212267e182d58a8e50723e7", size = 33924099, upload-time = "2026-01-20T15:13:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/f4/df/e475cd37d40221571e25465f0a39dd14123b8a3498f103e39e5938a2645f/pyrefly-0.49.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7ee11eefd1d551629ce1b25888814dbf758aac1a10279537d9425bc53f2d41c", size = 35026928, upload-time = "2026-01-20T15:13:38.403Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/fe9588b2cb4685c410ebf106bf1d28c66ed2727a5eeeabcfb51fec714143/pyrefly-0.49.0-py3-none-win32.whl", hash = "sha256:6196cb9b20ee977f64fa1fe87e06d3f7a222c5155031d21139fc60464a7a4b9c", size = 10675311, upload-time = "2026-01-20T15:13:40.99Z" }, + { url = "https://files.pythonhosted.org/packages/1a/dc/65fba26966bc2d9a9cbef620ef2a957f72bf3551822d6c250e3d36c2d0ee/pyrefly-0.49.0-py3-none-win_amd64.whl", hash = "sha256:15333b5550fd32a8f9a971ad124714d75f1906a67e48033dcc203258525bc7fd", size = 11418250, upload-time = "2026-01-20T15:13:43.321Z" }, + { url = "https://files.pythonhosted.org/packages/54/3c/9b0af11cbbfd57c5487af2d5d7322c30e7d73179171e1ffa4dda758dd286/pyrefly-0.49.0-py3-none-win_arm64.whl", hash = "sha256:4a57eebced37836791b681626a4be004ebd27221bc208f8200e1e2ca8a8b9510", size = 10962081, upload-time = "2026-01-20T15:13:45.82Z" }, +] + +[[package]] +name = "pystache" +version = "0.6.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/89/0a712ca22930b8c71bced8703e5bb45669c31690ea81afe15f6cb284550c/pystache-0.6.8.tar.gz", hash = "sha256:3707518e6a4d26dd189b07c10c669b1fc17df72684617c327bd3550e7075c72c", size = 101892, upload-time = "2025-03-18T11:54:47.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/78/ffd13a516219129cef6a754a11ba2a1c0d69f1e281af4f6bca9ed5327219/pystache-0.6.8-py3-none-any.whl", hash = "sha256:7211e000974a6e06bce2d4d5cad8df03bcfffefd367209117376e4527a1c3cb8", size = 82051, upload-time = "2025-03-18T11:54:45.813Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +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 = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[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-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { 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 = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[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 = "sse-starlette" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "textual" +version = "7.3.0" +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/6f/ee/620c887bfad9d6eba062dfa3b6b0e735e0259102e2667b19f21625ef598d/textual-7.3.0.tar.gz", hash = "sha256:3169e8ba5518a979b0771e60be380ab1a6c344f30a2126e360e6f38d009a3de4", size = 1590692, upload-time = "2026-01-15T16:32:02.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/1f/abeb4e5cb36b99dd37db72beb2a74d58598ccb35aaadf14624ee967d4a6b/textual-7.3.0-py3-none-any.whl", hash = "sha256:db235cecf969c87fe5a9c04d83595f506affc9db81f3a53ab849534d726d330a", size = 716374, upload-time = "2026-01-15T16:31:58.233Z" }, +] + +[[package]] +name = "textual-plotext" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "plotext" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/b0/e4e0f38df057db778252db0dd2c08522d7222b8537b6a0181d797b9044bd/textual_plotext-1.0.1.tar.gz", hash = "sha256:836f53a3316756609e194129a35c2875638e7958c261f541e0a794f7c98011be", size = 16489, upload-time = "2024-11-30T19:25:56.625Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/53/fba7da208f9d3f59254413660fa0aa6599f2aca806f3ae356670455fd4ea/textual_plotext-1.0.1-py3-none-any.whl", hash = "sha256:6b6bfd00b29f121ddf216eaaf9bdac9d688ed72f40028484d279a10cbbb169ed", size = 16558, upload-time = "2024-11-30T19:25:32.208Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "ty" +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/dc/b607f00916f5a7c52860b84a66dc17bc6988e8445e96b1d6e175a3837397/ty-0.0.13.tar.gz", hash = "sha256:7a1d135a400ca076407ea30012d1f75419634160ed3b9cad96607bf2956b23b3", size = 4999183, upload-time = "2026-01-21T13:21:16.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/df/3632f1918f4c0a33184f107efc5d436ab6da147fd3d3b94b3af6461efbf4/ty-0.0.13-py3-none-linux_armv6l.whl", hash = "sha256:1b2b8e02697c3a94c722957d712a0615bcc317c9b9497be116ef746615d892f2", size = 9993501, upload-time = "2026-01-21T13:21:26.628Z" }, + { url = "https://files.pythonhosted.org/packages/92/87/6a473ced5ac280c6ce5b1627c71a8a695c64481b99aabc798718376a441e/ty-0.0.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f15cdb8e233e2b5adfce673bb21f4c5e8eaf3334842f7eea3c70ac6fda8c1de5", size = 9860986, upload-time = "2026-01-21T13:21:24.425Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/d89ae375cf0a7cd9360e1164ce017f8c753759be63b6a11ed4c944abe8c6/ty-0.0.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0819e89ac9f0d8af7a062837ce197f0461fee2fc14fd07e2c368780d3a397b73", size = 9350748, upload-time = "2026-01-21T13:21:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a6/9ad58518056fab344b20c0bb2c1911936ebe195318e8acc3bc45ac1c6b6b/ty-0.0.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de79f481084b7cc7a202ba0d7a75e10970d10ffa4f025b23f2e6b7324b74886", size = 9849884, upload-time = "2026-01-21T13:21:21.886Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c3/8add69095fa179f523d9e9afcc15a00818af0a37f2b237a9b59bc0046c34/ty-0.0.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4fb2154cff7c6e95d46bfaba283c60642616f20d73e5f96d0c89c269f3e1bcec", size = 9822975, upload-time = "2026-01-21T13:21:14.292Z" }, + { url = "https://files.pythonhosted.org/packages/a4/05/4c0927c68a0a6d43fb02f3f0b6c19c64e3461dc8ed6c404dde0efb8058f7/ty-0.0.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00be58d89337c27968a20d58ca553458608c5b634170e2bec82824c2e4cf4d96", size = 10294045, upload-time = "2026-01-21T13:21:30.505Z" }, + { url = "https://files.pythonhosted.org/packages/b4/86/6dc190838aba967557fe0bfd494c595d00b5081315a98aaf60c0e632aaeb/ty-0.0.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72435eade1fa58c6218abb4340f43a6c3ff856ae2dc5722a247d3a6dd32e9737", size = 10916460, upload-time = "2026-01-21T13:21:07.788Z" }, + { url = "https://files.pythonhosted.org/packages/04/40/9ead96b7c122e1109dfcd11671184c3506996bf6a649306ec427e81d9544/ty-0.0.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:77a548742ee8f621d718159e7027c3b555051d096a49bb580249a6c5fc86c271", size = 10597154, upload-time = "2026-01-21T13:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7d/e832a2c081d2be845dc6972d0c7998914d168ccbc0b9c86794419ab7376e/ty-0.0.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da067c57c289b7cf914669704b552b6207c2cc7f50da4118c3e12388642e6b3f", size = 10410710, upload-time = "2026-01-21T13:21:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/31/e3/898be3a96237a32f05c4c29b43594dc3b46e0eedfe8243058e46153b324f/ty-0.0.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d1b50a01fffa140417fca5a24b658fbe0734074a095d5b6f0552484724474343", size = 9826299, upload-time = "2026-01-21T13:21:00.845Z" }, + { url = "https://files.pythonhosted.org/packages/bb/eb/db2d852ce0ed742505ff18ee10d7d252f3acfd6fc60eca7e9c7a0288a6d8/ty-0.0.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0f33c46f52e5e9378378eca0d8059f026f3c8073ace02f7f2e8d079ddfe5207e", size = 9831610, upload-time = "2026-01-21T13:21:05.842Z" }, + { url = "https://files.pythonhosted.org/packages/9e/61/149f59c8abaddcbcbb0bd13b89c7741ae1c637823c5cf92ed2c644fcadef/ty-0.0.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:168eda24d9a0b202cf3758c2962cc295878842042b7eca9ed2965259f59ce9f2", size = 9978885, upload-time = "2026-01-21T13:21:10.306Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/026d4e4af60a80918a8d73d2c42b8262dd43ab2fa7b28d9743004cb88d57/ty-0.0.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d4917678b95dc8cb399cc459fab568ba8d5f0f33b7a94bf840d9733043c43f29", size = 10506453, upload-time = "2026-01-21T13:20:56.633Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/8932833a4eca2df49c997a29afb26721612de8078ae79074c8fe87e17516/ty-0.0.13-py3-none-win32.whl", hash = "sha256:c1f2ec40daa405508b053e5b8e440fbae5fdb85c69c9ab0ee078f8bc00eeec3d", size = 9433482, upload-time = "2026-01-21T13:20:58.717Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fd/e8d972d1a69df25c2cecb20ea50e49ad5f27a06f55f1f5f399a563e71645/ty-0.0.13-py3-none-win_amd64.whl", hash = "sha256:8b7b1ab9f187affbceff89d51076038363b14113be29bda2ddfa17116de1d476", size = 10319156, upload-time = "2026-01-21T13:21:03.266Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c2/05fdd64ac003a560d4fbd1faa7d9a31d75df8f901675e5bed1ee2ceeff87/ty-0.0.13-py3-none-win_arm64.whl", hash = "sha256:1c9630333497c77bb9bcabba42971b96ee1f36c601dd3dcac66b4134f9fa38f0", size = 9808316, upload-time = "2026-01-21T13:20:54.053Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +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 = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000000000000000000000000000000000000..1ea4edc2aa07a5955a5df4d2a9fcd2652db5f192 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,28 @@ +import path from 'node:path'; + +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + outDir: 'src/aspara/dashboard/static/dist', + emptyOutDir: true, + sourcemap: false, + target: 'es2020', + rollupOptions: { + input: { + 'pages/project-detail': path.resolve(__dirname, 'src/aspara/dashboard/static/js/pages/project-detail.js'), + 'pages/run-detail': path.resolve(__dirname, 'src/aspara/dashboard/static/js/pages/run-detail.js'), + 'tag-editor': path.resolve(__dirname, 'src/aspara/dashboard/static/js/tag-editor.js'), + 'note-editor': path.resolve(__dirname, 'src/aspara/dashboard/static/js/note-editor.js'), + 'projects-list': path.resolve(__dirname, 'src/aspara/dashboard/static/js/projects-list.js'), + 'runs-list': path.resolve(__dirname, 'src/aspara/dashboard/static/js/runs-list/index.js'), + 'settings-menu': path.resolve(__dirname, 'src/aspara/dashboard/static/js/settings-menu.js'), + 'components/delete-dialog': path.resolve(__dirname, 'src/aspara/dashboard/static/js/components/delete-dialog.js'), + }, + output: { + format: 'es', + entryFileNames: '[name].js', + }, + }, + }, +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000000000000000000000000000000000000..ed582be3c5da430e192721364b0624777f9c3849 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,44 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Test environment + environment: 'jsdom', + + // Test file patterns + include: ['**/tests/**/*.test.js'], + + // Exclude Playwright tests + exclude: ['**/node_modules/**', '**/*.spec.js', '**/e2e/**'], + + // Setup files + setupFiles: ['./tests/vitest-canvas-setup.js', './tests/vitest-setup.js'], + + // Global setup (no need to import describe, it, expect, etc.) + globals: true, + + // Coverage settings + coverage: { + provider: 'v8', + reporter: ['text', 'lcov', 'html'], + include: ['src/aspara/dashboard/static/js/**/*.js'], + exclude: ['src/aspara/dashboard/static/js/**/*.test.js', 'src/aspara/dashboard/static/js/**/*.spec.js'], + thresholds: { + branches: 50, + functions: 50, + lines: 50, + statements: 50, + }, + }, + + // Test timeout + testTimeout: 10000, + + // DOM environment settings + environmentOptions: { + jsdom: { + url: 'http://localhost', + }, + }, + }, +});