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..27254772553fc550e13e658f3e5c79a3b3c19506 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +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 + + 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 + + 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/biome.json b/biome.json new file mode 100644 index 0000000000000000000000000000000000000000..e8886ea74f384be00a2fd1072bb4937c50249660 --- /dev/null +++ b/biome.json @@ -0,0 +1,35 @@ +{ + "$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" + } + }, + "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..d84f48b29089c1145d4a36afb536e2ab4efa550e --- /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.0.18", + "@vitest/ui": "^4.0.18", + "canvas": "npm:@napi-rs/canvas@^0.1.95", + "happy-dom": "^20.7.0", + "heroicons": "^2.2.0", + "jsdom": "^26.1.0", + "tailwindcss": "^4.2.1", + "vite": "^7.3.1", + "vitest": "^4.0.18" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000000000000000000000000000000000000..0589bf6dae3692a1f7175fc314f9200a0129b89d --- /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({ + // E2Eテストのみを対象にする(Vitestとの競合を避ける) + testDir: './tests/e2e', + + // 並列実行の worker 数 + // CI では 2 workers、ローカルでは制限なし + workers: process.env.CI ? 2 : undefined, + + // テストの実行タイムアウト + timeout: 30 * 1000, + + // テスト実行の期待値 + expect: { + // 要素が表示されるまでの最大待機時間 + timeout: 5000, + }, + + // 失敗したテストのスクリーンショットを撮る + use: { + // ベースURL + baseURL: `http://localhost:${BASE_PORT}`, + + // スクリーンショットを撮る + screenshot: 'only-on-failure', + + // トレースを記録する + trace: 'on-first-retry', + + // ダウンロードを許可 + acceptDownloads: true, + }, + + // テスト実行のレポート形式 + // 'list' はコンソール出力のみ、HTMLレポートは生成しない + reporter: process.env.CI ? 'github' : 'list', + + // テスト前にサーバーを自動起動 + webServer: { + command: `uv run aspara dashboard --port ${BASE_PORT}`, + port: BASE_PORT, + reuseExistingServer: !process.env.CI, + timeout: 60 * 1000, + }, + + // プロジェクト設定 + 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..5c72fbdcc168ecade4666f26f06af0b4bc82193b --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2831 @@ +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.0.18 + version: 4.0.18(vitest@4.0.18) + '@vitest/ui': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18) + canvas: + specifier: npm:@napi-rs/canvas@^0.1.95 + version: '@napi-rs/canvas@0.1.95' + happy-dom: + specifier: ^20.7.0 + version: 20.7.0 + 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.1 + version: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.3.2)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(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.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@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.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + 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.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + 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.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + 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.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + 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.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + 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.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + 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.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + 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.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + 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.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + 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.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@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.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + 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.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@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.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + 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.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@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.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + 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.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + 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@25.3.2': + resolution: {integrity: sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==} + + '@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.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/ui@4.0.18': + resolution: {integrity: sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==} + peerDependencies: + vitest: 4.0.18 + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + + 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@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + 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@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + 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.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + 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.7.0: + resolution: {integrity: sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==} + 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@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + 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.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + 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@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + 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 + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + 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.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + 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.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + 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/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 + + 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.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.25.12': + 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@25.3.2': + dependencies: + undici-types: 7.18.2 + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.3.2 + + '@vitest/coverage-v8@4.0.18(vitest@4.0.18)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.12 + 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: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@25.3.2)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(lightningcss@1.31.1) + + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/ui@4.0.18(vitest@4.0.18)': + dependencies: + '@vitest/utils': 4.0.18 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@25.3.2)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(lightningcss@1.31.1) + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + + 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@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + chai@6.2.2: {} + + 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@1.7.0: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + 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.3.3: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + happy-dom@20.7.0: + dependencies: + '@types/node': 25.3.2 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.19.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@3.10.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.0.3: {} + + 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@7.18.2: {} + + vite@6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1): + dependencies: + esbuild: 0.25.12 + 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': 25.3.2 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + + vite@7.3.1(@types/node@25.3.2)(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': 25.3.2 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + + vitest@4.0.18(@types/node@25.3.2)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(lightningcss@1.31.1): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.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: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.3.2 + '@vitest/ui': 4.0.18(vitest@4.0.18) + happy-dom: 20.7.0 + jsdom: 26.1.0(@napi-rs/canvas@0.1.95) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + 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: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..9eb95693d1397ca19cf6518e039837d565ee4ace --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,123 @@ +[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", + "python-multipart>=0.0.22", +] + +dashboard = [ + "uvicorn>=0.27.0", + "sse-starlette>=1.8.0", + "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.0.0", +] + +remote = [ + "requests>=2.31.0", +] + +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>=8.3.4", + "pytest-asyncio>=1.0.0", + "pytest-cov>=7.0.0", + "mkdocs>=1.6.0", + "mkdocs-material>=9.6.0", + "mkdocstrings[python]>=0.24.0", + "mkdocs-autorefs>=0.5.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", + "bandit>=1.9.3", + "httpx>=0.28.1", + "mypy>=1.19.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"] + +[tool.mypy] +ignore_missing_imports = true 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..e748d0d304282c691e57ed8beb2666e84738bfa0 --- /dev/null +++ b/src/aspara/catalog/project_catalog.py @@ -0,0 +1,202 @@ +""" +ProjectCatalog - Catalog for discovering and managing projects. + +This module provides functionality for listing, getting, and deleting projects +in the data directory. +""" + +import logging +import shutil +from datetime import datetime +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 + + +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 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 + """ + import os + + projects: list[ProjectInfo] = [] + if not self.data_dir.exists(): + return projects + + 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 + + # Collect run files with stat info in single pass + run_files_mtime: list[float] = [] + with os.scandir(project_entry.path) as file_entries: + for file_entry in file_entries: + if ( + file_entry.name.endswith(".jsonl") + and not file_entry.name.endswith(".wal.jsonl") + and not file_entry.name.endswith(".meta.jsonl") + ): + # stat() result is cached by scandir + run_files_mtime.append(file_entry.stat().st_mtime) + + 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)) + else: + last_update = datetime.fromtimestamp(project_entry.stat().st_mtime) + + projects.append( + ProjectInfo( + name=project_entry.name, + run_count=run_count, + last_update=last_update, + ) + ) + except (OSError, PermissionError): + pass + + return sorted(projects, key=lambda p: p.name) + + 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 + run_files = [f for f in project_dir.iterdir() if f.suffix in [".jsonl", ".db", ".wal"]] + run_count = len(run_files) + + # Get last update time from run files + last_update = datetime.fromtimestamp(project_dir.stat().st_mtime) + if run_files: + last_update = max(datetime.fromtimestamp(f.stat().st_mtime) for f in run_files) + + 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..6e4624579164b0399c3f97b3ee4cc76e25c2197d --- /dev/null +++ b/src/aspara/catalog/run_catalog.py @@ -0,0 +1,738 @@ +""" +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 + """ + 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"): + return (project, filename[:-10], "wal") + elif filename.endswith(".meta.json"): + return (project, filename[:-10], "meta") + elif filename.endswith(".jsonl"): + return (project, filename[:-6], "metrics") + + return None + + 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 + is_corrupted = False + error_message = None + last_update = None + + # Use file modification time as last_update + if run_file.exists(): + last_update = datetime.fromtimestamp(run_file.stat().st_mtime) + + if not run_file.exists() and not metadata_file.exists(): + is_corrupted = True + error_message = "Run file not found" + elif run_file.exists() and run_file.stat().st_size == 0 and not metadata_file.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) + for run_file in list(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.) + """ + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, 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.) + """ + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, 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 + """ + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, 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..c189e86ec0d483e52b598b985f851ba8893b6af8 --- /dev/null +++ b/src/aspara/catalog/watcher.py @@ -0,0 +1,507 @@ +""" +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 + + 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 + """ + 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"): + return (project, filename[:-10], "wal") + elif filename.endswith(".meta.json"): + return (project, filename[:-10], "meta") + elif filename.endswith(".jsonl"): + return (project, filename[:-6], "metrics") + + return None + + 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: + changed_path = Path(changed_path_str).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: + current_size = self._file_sizes.get(file_path, 0) + with open(file_path) as f: + f.seek(current_size) + 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..64ada62c9293e36f486b7f299e0713cf3456cd4d --- /dev/null +++ b/src/aspara/cli.py @@ -0,0 +1,403 @@ +#!/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 uvicorn + +from aspara.config import get_data_dir, get_storage_backend + + +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 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" + + if data_dir is None: + data_dir = str(get_data_dir()) + + 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) + + 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) + """ + if data_dir is None: + data_dir = str(get_data_dir()) + + 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 + + if data_dir is None: + data_dir = str(get_data_dir()) + + os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir) + + 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 + if data_dir is None: + data_dir = str(get_data_dir()) + + 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" + + 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 main() -> None: + """ + CLI main entry point + """ + parser = argparse.ArgumentParser(description="Aspara management tool") + subparsers = parser.add_subparsers(dest="command", 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)", + ) + + 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, + ) + else: + port = find_available_port(start_port=3141) + if port is None: + print("Error: No available port found!") + return + + run_dashboard(port=port) + + +if __name__ == "__main__": + main() diff --git a/src/aspara/config.py b/src/aspara/config.py new file mode 100644 index 0000000000000000000000000000000000000000..efdd44751be28fdecddb91adf241b755bf3d5567 --- /dev/null +++ b/src/aspara/config.py @@ -0,0 +1,214 @@ +"""Configuration and environment handling for Aspara.""" + +import os +from pathlib import Path + +from pydantic import BaseModel, Field + +__all__ = [ + "ResourceLimits", + "get_data_dir", + "get_resource_limits", + "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" 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..ad9fa8a34038a87652632156e702e308cf1f4e4a --- /dev/null +++ b/src/aspara/dashboard/main.py @@ -0,0 +1,145 @@ +""" +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.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response + +from aspara.config import 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" + + # 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 + logger.info(f"[DEV MODE] Cancelling {len(app_state.active_sse_tasks)} active SSE tasks") + for task in list(app_state.active_sse_tasks): + task.cancel() + + # Wait briefly 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=0.1, + ) + logger.info("[DEV MODE] SSE tasks cancelled, shutdown complete") + else: + # Production mode: graceful shutdown with 30 second timeout + await asyncio.sleep(0.5) + + +app = FastAPI( + title="Aspara Dashboard", + description="Real-time metrics visualization for machine learning experiments", + docs_url="/docs/dashboard", # /docs/dashboard としてアクセスできるようにする + redoc_url=None, # ReDocは使わない + lifespan=lifespan, +) + +# Security headers middleware +app.add_middleware(SecurityHeadersMiddleware) # ty: ignore[invalid-argument-type] + +# CORS middleware - credentials disabled for security with wildcard origins +# Note: allow_credentials=True with allow_origins=["*"] is a security vulnerability +# as it allows any site to make credentialed requests to our API +app.add_middleware( + CORSMiddleware, # ty: ignore[invalid-argument-type] + allow_origins=["*"], + allow_credentials=False, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["Content-Type", "X-Requested-With"], +) + +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..3920a09ffab9d9e1046763a03101814ede0666a5 --- /dev/null +++ b/src/aspara/dashboard/routes/api_routes.py @@ -0,0 +1,432 @@ +""" +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 io +import logging +import os +import zipfile +from collections import defaultdict +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.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() + + +@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) + 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(): + size = entry.stat().st_size # Uses cached stat + artifact_entries.append((entry.name, entry.path, size)) + total_size += size + + 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)"), + ) + + # Create ZIP file in memory + zip_buffer = io.BytesIO() + + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + for filename, file_path, _ in artifact_entries: + # Add file to zip with just the filename (no directory structure) + zip_file.write(file_path, filename) + + zip_buffer.seek(0) + + # Generate filename with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + zip_filename = f"{project}_{run}_artifacts_{timestamp}.zip" + + # Return as streaming response + # Encode filename for Content-Disposition header to prevent header injection + # Use RFC 5987 encoding for non-ASCII characters + import urllib.parse + + encoded_filename = urllib.parse.quote(zip_filename, safe="") + return StreamingResponse( + io.BytesIO(zip_buffer.read()), + 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'", + ) + + if not runs: + if format == "msgpack": + raise HTTPException(status_code=400, detail="No runs specified") + return JSONResponse(content={"error": "No runs specified"}) + + run_list = [r.strip() for r in runs.split(",") if r.strip()] + if not run_list: + if format == "msgpack": + raise HTTPException(status_code=400, detail="No valid runs specified") + return JSONResponse(content={"error": "No valid runs specified"}) + + # Validate number of runs + limits = get_resource_limits() + if len(run_list) > limits.max_metric_names: # Reuse max_metric_names limit for runs + raise HTTPException( + status_code=400, + detail=f"Too many runs: {len(run_list)} (max: {limits.max_metric_names})", + ) + + # Validate each run name + for run_name in run_list: + try: + validators.validate_run_name(run_name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + + # 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}: {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 (synchronous call inside async endpoint) + metadata = 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 = 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}: {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. + """ + # Use RunCatalog metadata API + metadata = 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 = 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}: {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..f1322f8c16316d9a3776d7208f4a2fad93a2cd3a --- /dev/null +++ b/src/aspara/dashboard/routes/html_routes.py @@ -0,0 +1,234 @@ +""" +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 ..dependencies import ( + ProjectCatalogDep, + RunCatalogDep, + ValidatedProject, + ValidatedRun, +) +from ..services.template_service import ( + TemplateService, + create_breadcrumbs, + render_mustache_response, +) + +router = APIRouter() + + +@router.get("/") +async def home( + request: Request, + project_catalog: ProjectCatalogDep, +) -> HTMLResponse: + """Render the projects list page.""" + projects = project_catalog.get_projects() + + # Format projects for template, including metadata tags + formatted_projects = [] + for project in projects: + metadata = project_catalog.get_metadata(project.name) + 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 project_catalog.exists(project): + raise HTTPException(status_code=404, detail=f"Project '{project}' not found") + + runs = 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 project_catalog.exists(project): + raise HTTPException(status_code=404, detail=f"Project '{project}' not found") + + runs = 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 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 = 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()] + + # Get run start time from DataFrame + start_time = None + if len(df_metrics) > 0 and "timestamp" in df_metrics.columns: + start_time = df_metrics.select("timestamp").to_series().min() + + 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.strftime("%B %d, %Y at %I:%M %p") if isinstance(start_time, datetime) else "N/A"), + "duration": "N/A", # We don't have duration data in current format + "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..66aba235860fe4189f45a1737fa660177252601e --- /dev/null +++ b/src/aspara/dashboard/routes/sse_routes.py @@ -0,0 +1,239 @@ +""" +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 get_resource_limits, is_dev_mode +from aspara.models import MetricRecord, StatusRecord +from aspara.utils import validators + +from ..dependencies import RunCatalogDep, ValidatedProject + +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 + + if not runs: + + async def no_runs_error_generator(): + yield {"event": "error", "data": "No runs specified"} + + return EventSourceResponse(no_runs_error_generator()) + + # Parse and validate run names + run_list = [r.strip() for r in runs.split(",") if r.strip()] + + if not run_list: + + async def no_valid_runs_error_generator(): + yield {"event": "error", "data": "No valid runs specified"} + + return EventSourceResponse(no_valid_runs_error_generator()) + + # Validate run count limit + limits = get_resource_limits() + if len(run_list) > limits.max_metric_names: + too_many_runs_msg = f"Too many runs: {len(run_list)} (max: {limits.max_metric_names})" + + async def too_many_runs_error_generator(): + yield {"event": "error", "data": too_many_runs_msg} + + return EventSourceResponse(too_many_runs_error_generator()) + + # Validate each run name + for run_name in run_list: + try: + validators.validate_run_name(run_name) + except ValueError: + invalid_run_msg = f"Invalid run name: {run_name}" + + async def invalid_run_error_generator(msg: str = invalid_run_msg): + yield {"event": "error", "data": msg} + + return EventSourceResponse(invalid_run_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: + logger.error(f"[SSE] Exception in event_generator: {e}", exc_info=True) + yield {"event": "error", "data": str(e)} + 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=1.0) + 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()) 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..72c97bc9b12e91e129614d4dc0c94bf3f8e312e2 --- /dev/null +++ b/src/aspara/dashboard/services/template_service.py @@ -0,0 +1,162 @@ +""" +Template rendering service for Aspara Dashboard. + +Provides Mustache template rendering and context formatting utilities. +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any + +import pystache + +from aspara.catalog import ProjectInfo, RunInfo + +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().year, + "page_title": context.get("page_title", "Aspara"), + }) + + # 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..7760617318a49b5f15068339a245f1c88141c448 --- /dev/null +++ b/src/aspara/dashboard/static/css/input.css @@ -0,0 +1,171 @@ +@import "tailwindcss"; +@source "../../templates/**/*.mustache"; + +@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); +} + +/* Accessibility: respect reduced motion preference */ +@media (prefers-reduced-motion: reduce) { + #runs-sidebar { + transition: none; + } +} + +.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; +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .card-interactive { + @apply transition-none; + } +} 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..762f0883004e58235978d3225043737781b6ed8d --- /dev/null +++ b/src/aspara/dashboard/static/js/api/delete-api.js @@ -0,0 +1,61 @@ +/** + * Delete API utility functions + * Pure API calls without UI logic + */ + +/** + * 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) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Unknown error'); + } + + // 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) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Unknown error'); + } + + // 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..77f7ba82a0648f4fe5440f41da9a21334c7cbe9e --- /dev/null +++ b/src/aspara/dashboard/static/js/chart.js @@ -0,0 +1,420 @@ +/** + * 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'; + +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; + + // 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.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%'; + + 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 changes. + * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null + */ + _getDataRanges() { + // Check if data reference changed + if (this.data?.series !== this._lastDataRef) { + this._lastDataRef = this.data?.series; + this._cachedDataRanges = this._calculateDataRanges(); + } + return this._cachedDataRanges; + } + + /** + * Calculate data ranges from all series. + * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null + */ + _calculateDataRanges() { + if (!this.data?.series?.length) return null; + + let xMin = Number.POSITIVE_INFINITY; + let xMax = Number.NEGATIVE_INFINITY; + let yMin = Number.POSITIVE_INFINITY; + let yMax = Number.NEGATIVE_INFINITY; + + for (const series of this.data.series) { + if (!series.data?.steps?.length) continue; + const { steps, values } = series.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 (values[i] < yMin) yMin = values[i]; + if (values[i] > yMax) yMax = values[i]; + } + } + + if (xMin === Number.POSITIVE_INFINITY) return null; + + return { xMin, xMax, yMin, yMax }; + } + + 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; + } + + const yRange = yMax - yMin; + const yMinPadded = yMin - yRange * Chart.Y_PADDING_RATIO; + const yMaxPadded = yMax + yRange * Chart.Y_PADDING_RATIO; + + this.renderer.drawGrid(margin, plotWidth, plotHeight, xMin, xMax, yMinPadded, yMaxPadded); + this.renderer.drawAxisLabels(margin, plotWidth, plotHeight, xMin, xMax, yMinPadded, yMaxPadded); + + // 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(); + + for (let i = 0; i < steps.length; i++) { + const x = margin + ((steps[i] - xMin) / (xMax - xMin)) * plotWidth; + const y = margin + plotHeight - ((values[i] - yMinPadded) / (yMaxPadded - yMinPadded)) * plotHeight; + + if (i === 0) { + this.ctx.moveTo(x, y); + } 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(); + } + + 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.removeEventListeners(); + } + 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..2f5c93800c5bb5943d0ab273f44af2690a401ab6 --- /dev/null +++ b/src/aspara/dashboard/static/js/chart/controls.js @@ -0,0 +1,224 @@ +import { ICON_DOWNLOAD, ICON_FULLSCREEN, ICON_RESET_ZOOM } from '../html-utils.js'; + +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; + + // Document click handler (stored for cleanup) + this.documentClickHandler = 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.createResetButton(); + this.createFullSizeButton(); + this.createDownloadButton(); + + this.buttonContainer.appendChild(this.resetButton); + this.buttonContainer.appendChild(this.fullSizeButton); + this.buttonContainer.appendChild(this.downloadButton); + this.chart.container.appendChild(this.buttonContainer); + } + + createResetButton() { + this.resetButton = document.createElement('button'); + this.resetButton.innerHTML = ICON_RESET_ZOOM; + this.resetButton.title = 'Reset zoom'; + this.resetButton.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; + `; + + this.attachButtonHover(this.resetButton); + this.resetButton.addEventListener('click', () => this.chart.resetZoom()); + } + + createFullSizeButton() { + this.fullSizeButton = document.createElement('button'); + this.fullSizeButton.innerHTML = ICON_FULLSCREEN; + this.fullSizeButton.title = 'Fit to full size'; + this.fullSizeButton.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; + `; + + this.attachButtonHover(this.fullSizeButton); + this.fullSizeButton.addEventListener('click', () => this.fitToFullSize()); + } + + createDownloadButton() { + this.downloadButton = document.createElement('button'); + this.downloadButton.innerHTML = ICON_DOWNLOAD; + this.downloadButton.title = 'Download data'; + this.downloadButton.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; + position: relative; + `; + + this.downloadMenu = document.createElement('div'); + 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.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()); + + // Store handler for cleanup + this.documentClickHandler = (e) => { + if (!this.downloadButton.contains(e.target)) { + this.toggleDownloadMenu(false); + } + }; + document.addEventListener('click', this.documentClickHandler); + } + + 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(); + } + } else { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } + } + } + + toggleDownloadMenu(forceState) { + const isVisible = this.downloadMenu.style.display === 'flex'; + const newState = forceState !== undefined ? forceState : !isVisible; + + this.downloadMenu.style.display = newState ? 'flex' : 'none'; + } + + /** + * 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.buttonContainer?.parentNode) { + this.buttonContainer.parentNode.removeChild(this.buttonContainer); + } + this.buttonContainer = null; + this.resetButton = null; + this.fullSizeButton = null; + this.downloadButton = null; + this.downloadMenu = 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..7a698c0026ac89cc81fd59b5576bb9d3ccbc2c38 --- /dev/null +++ b/src/aspara/dashboard/static/js/chart/interaction-utils.js @@ -0,0 +1,131 @@ +/** + * Pure utility functions for chart interaction + * These functions have no side effects and are easy to test + */ + +/** + * Calculate data ranges from series data (SoA format) + * @param {Array} series - Array of series objects with data in SoA format { steps: [], values: [] } + * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null if no data + */ +export function calculateDataRanges(series) { + let xMin = Number.POSITIVE_INFINITY; + let xMax = Number.NEGATIVE_INFINITY; + let yMin = Number.POSITIVE_INFINITY; + let yMax = Number.NEGATIVE_INFINITY; + + 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 (values[i] < yMin) yMin = values[i]; + if (values[i] > yMax) yMax = values[i]; + } + } + + return xMin === Number.POSITIVE_INFINITY ? null : { 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..42319de41c6240cfab167a99c7351fe09b474d30 --- /dev/null +++ b/src/aspara/dashboard/static/js/chart/interaction.js @@ -0,0 +1,421 @@ +import { binarySearchByStep, calculateDataRanges, findNearestStepBinary } from './interaction-utils.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; + + // 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 changes. + */ + invalidateRangesCache() { + this._cachedRanges = null; + this._lastDataRef = null; + } + + /** + * Get data ranges, using cache if data hasn't changed. + * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null + */ + _getDataRanges() { + // Check if data reference changed (simple identity check) + if (this.chart.data?.series !== this._lastDataRef) { + this._lastDataRef = this.chart.data?.series; + this._cachedRanges = calculateDataRanges(this.chart.data?.series || []); + } + 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); + } + + /** + * Remove event listeners from canvas. + */ + removeEventListeners() { + if (this.chart.canvas) { + if (this.mousemoveHandler) { + this.chart.canvas.removeEventListener('mousemove', this.mousemoveHandler); + } + if (this.mouseleaveHandler) { + this.chart.canvas.removeEventListener('mouseleave', this.mouseleaveHandler); + } + if (this.mousedownHandler) { + this.chart.canvas.removeEventListener('mousedown', this.mousedownHandler); + } + if (this.mouseupHandler) { + this.chart.canvas.removeEventListener('mouseup', this.mouseupHandler); + } + if (this.dblclickHandler) { + this.chart.canvas.removeEventListener('dblclick', this.dblclickHandler); + } + if (this.contextmenuHandler) { + this.chart.canvas.removeEventListener('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 yRange = yMax - yMin; + const yMinPadded = yMin - yRange * this.chart.constructor.Y_PADDING_RATIO; + const yMaxPadded = yMax + yRange * 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) { + const x = margin + ((result.step - xMin) / (xMax - xMin)) * plotWidth; + const y = margin + plotHeight - ((result.value - yMinPadded) / (yMaxPadded - yMinPadded)) * plotHeight; + + 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; + const currentYRange = currentYMax - currentYMin; + + const newYMin = currentYMin + yMinRatio * currentYRange; + const 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..d09187e0fc21edc5ef2f4a048b713b15d20d5e01 --- /dev/null +++ b/src/aspara/dashboard/static/js/chart/renderer.js @@ -0,0 +1,137 @@ +export class ChartRenderer { + constructor(chart) { + this.chart = chart; + } + + drawGrid(margin, plotWidth, plotHeight, xMin, xMax, yMin, yMax) { + 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(); + } + + 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) { + 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'; + 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); + } + } + } + + 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/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..3935528d295701a9a5b41c43f455ea8d99ddc085 --- /dev/null +++ b/src/aspara/dashboard/static/js/components/run-selector.js @@ -0,0 +1,283 @@ +/** + * Run selector component. + * Handles run selection, filtering, and sorting UI in the project detail page. + */ + +/** + * 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; + + 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) { + this.filterInput.addEventListener('input', (e) => { + this.filterRuns(e.target.value); + }); + } + + for (const checkbox of this.runCheckboxes) { + checkbox.addEventListener('change', (e) => { + this.handleRunSelection(e.target); + }); + } + + const sortButtons = document.querySelectorAll('[data-sort-runs]'); + for (const button of sortButtons) { + button.addEventListener('click', () => { + 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(); + }); + } + + 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'); + + if (!pattern || regex.test(runName)) { + runItem.style.display = ''; + if (pattern) { + if (!this.manuallyDeselectedRuns.has(runName)) { + checkbox.checked = true; + this.selectedRuns.add(runName); + } + } else { + if (!this.manuallyDeselectedRuns.has(runName)) { + checkbox.checked = true; + this.selectedRuns.add(runName); + } + } + } else { + runItem.style.display = 'none'; + } + } + + 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'; + } + } +} 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..80ba1f1d9c679fb48b117756cc5bcbe56b936cff --- /dev/null +++ b/src/aspara/dashboard/static/js/html-utils.js @@ -0,0 +1,23 @@ +/** + * 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 = ``; + +/** + * 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; +} 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..2c4cbae42b45c244c40047d529ef49047ca62acb --- /dev/null +++ b/src/aspara/dashboard/static/js/metrics/chart-layout.js @@ -0,0 +1,110 @@ +/** + * Shared chart layout utilities. + * Provides common layout calculation and grid management for chart pages. + */ + +/** Chart base width map for S/M/L sizes */ +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. + * + * @param {HTMLElement} container - Container element with chart divs + * @param {number} chartHeight - Height to apply in pixels + * @param {string} [selector='[id^="chart-"]'] - CSS selector for chart elements + */ +export function updateChartHeights(container, chartHeight, selector = '[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..e359690f95013c1acc68dc23ce1d312445a0f476 --- /dev/null +++ b/src/aspara/dashboard/static/js/metrics/metric-chart-factory.js @@ -0,0 +1,61 @@ +/** + * 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 }} + */ +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, '_')}`; + + // Header section + const chartHeader = document.createElement('div'); + chartHeader.className = 'flex items-center justify-between mb-6'; + + const chartTitle = document.createElement('h3'); + 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'; + chartContainer.appendChild(chartDiv); + + return { container: chartContainer, chartDiv, chartId }; +} + +/** + * 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..91d2aa3e9a756f8264b0b8c5960866ae7c05a24c --- /dev/null +++ b/src/aspara/dashboard/static/js/metrics/metrics-data-service.js @@ -0,0 +1,492 @@ +/** + * 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 { 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) + */ + 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.currentSSERuns = ''; + this.isReconnecting = false; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 10; + this.baseReconnectDelay = 1000; + + // 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; + } + + /** + * 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(`/api/projects/${encodeURIComponent(this.project)}/runs/metrics?runs=${encodeURIComponent(runsList)}&format=msgpack`); + + 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.isReconnecting = false; + this.reconnectAttempts = 0; + }; + + this.sseMetricHandler = (event) => { + try { + const metric = JSON.parse(event.data); + if (metric.timestamp) { + const ts = new Date(metric.timestamp).getTime(); + if (!Number.isNaN(ts) && ts > this.lastSSETimestamp) { + this.lastSSETimestamp = ts; + } + } + this.handleMetricUpdate(metric); + } catch (error) { + console.error('Error processing SSE metric:', error); + } + }; + + this.sseStatusHandler = (event) => { + try { + const statusData = JSON.parse(event.data); + if (statusData.timestamp) { + const ts = new Date(statusData.timestamp).getTime(); + if (!Number.isNaN(ts) && ts > this.lastSSETimestamp) { + this.lastSSETimestamp = ts; + } + } + 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.isReconnecting) { + console.log('[SSE] Already reconnecting, skipping'); + return; + } + + // Check max retry count + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('[SSE] Max reconnection attempts reached, giving up'); + this.isReconnecting = false; + return; + } + + this.isReconnecting = true; + this.reconnectAttempts++; + + // Exponential backoff: 1s, 2s, 4s, 8s... (max 30s) + const delay = Math.min(this.baseReconnectDelay * 2 ** (this.reconnectAttempts - 1), 30000); + console.log(`[SSE] Reconnect attempt ${this.reconnectAttempts}/${this.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.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.isReconnecting = false; + } catch (error) { + console.error('[SSE] Reconnection failed:', error); + this.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 = `/api/projects/${encodeURIComponent(this.project)}/runs/metrics?runs=${encodeURIComponent(runsList)}&format=msgpack&since=${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) { + // Remove event listeners before closing to prevent memory leaks + if (this.sseOpenHandler) { + this.eventSource.removeEventListener('open', this.sseOpenHandler); + } + if (this.sseMetricHandler) { + this.eventSource.removeEventListener('metric', this.sseMetricHandler); + } + if (this.sseStatusHandler) { + this.eventSource.removeEventListener('status', this.sseStatusHandler); + } + if (this.sseErrorHandler) { + this.eventSource.removeEventListener('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.reconnectAttempts = 0; + } +} 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..9644f3af559b68eeca49cd7517177d203257ad1b --- /dev/null +++ b/src/aspara/dashboard/static/js/note-editor.js @@ -0,0 +1,302 @@ +/** + * Inline note editor functionality + * GitHub Issue-style inline editing for project/run notes + */ + +import { + EMPTY_NOTE_PLACEHOLDER, + createSaveNoteRequestBody, + extractNoteFromResponse, + extractNoteText, + formatNoteForDisplay, + getEditButtonHTML, + isNoteEmpty, +} from './note-editor-utils.js'; +import { guardReadOnly } from './read-only-guard.js'; + +class NoteEditor { + constructor() { + this.isEditing = false; + this.originalValue = ''; + this.currentElement = null; + this.currentApiEndpoint = 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; + + const wrapper = this.createNoteWrapper(element, currentNote, editButtonContainerId); + element.parentNode.replaceChild(wrapper, element); + + this.attachEventListeners(wrapper, apiEndpoint); + } + + /** + * 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 = ` + +
+ + +
+ + `; + + wrapper.appendChild(display); + wrapper.appendChild(edit); + + return wrapper; + } + + /** + * Attach event listeners to note wrapper + */ + attachEventListeners(wrapper, apiEndpoint) { + // Edit button might be outside wrapper, so search globally + const editBtn = document.querySelector('.note-edit-btn'); + 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)); + } + + // Click on placeholder text enters edit mode + if (noteContent) { + noteContent.addEventListener('click', () => { + if (isNoteEmpty(noteContent.textContent)) { + this.startEditing(wrapper, apiEndpoint); + } + }); + } + + saveBtn.addEventListener('click', () => this.saveNote(wrapper, apiEndpoint)); + cancelBtn.addEventListener('click', () => this.cancelEditing(wrapper)); + + // Save on Ctrl+Enter, Cancel on Escape + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && e.ctrlKey) { + this.saveNote(wrapper, apiEndpoint); + } else if (e.key === 'Escape') { + this.cancelEditing(wrapper); + } + }); + } + + /** + * 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) { + const errorData = await response.json(); + throw new Error(errorData.detail || `Server error: ${response.status}`); + } + + const updatedMetadata = await response.json(); + + 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; + } +} + +/** + * 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 + */ +async function initNoteEditorFromDOM(elementId) { + const noteElement = document.getElementById(elementId); + if (!noteElement) return; + + const apiEndpoint = noteElement.dataset.apiEndpoint; + const editBtnId = noteElement.dataset.editBtnId; + + if (!apiEndpoint) { + console.error(`Note editor: missing data-api-endpoint on #${elementId}`); + return; + } + + const noteEditor = new NoteEditor(); + + try { + const response = await fetch(apiEndpoint); + 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); + } +} + +// 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..f88f635e91733039717b43cce82158a26164ddb7 --- /dev/null +++ b/src/aspara/dashboard/static/js/pages/base-chart-page.js @@ -0,0 +1,309 @@ +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; + } + + /** + * 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() { + if (this.sizeSBtn) { + this.sizeSBtn.addEventListener('click', () => this.setChartSize('S')); + } + if (this.sizeMBtn) { + this.sizeMBtn.addEventListener('click', () => this.setChartSize('M')); + } + if (this.sizeLBtn) { + this.sizeLBtn.addEventListener('click', () => this.setChartSize('L')); + } + 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; + + 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) { + 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; + } + + /** + * 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; + } + 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..ab94ad76279c7985d81e54e9041af4422f25a317 --- /dev/null +++ b/src/aspara/dashboard/static/js/pages/project-detail.js @@ -0,0 +1,291 @@ +/** + * 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 { MetricsDataService } from '../metrics/metrics-data-service.js'; +import { convertToChartFormat } from '../metrics/metrics-utils.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.init(); + this.setupProjectSpecificListeners(); + this.loadInitialData(); + } + + 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.syncZoomCheckbox.addEventListener('change', (e) => { + this.syncZoom = e.target.checked; + localStorage.setItem('sync_zoom', this.syncZoom); + if (!this.syncZoom) { + this.globalZoomState = null; + } + }); + } + + // 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.toggleSidebarBtn.addEventListener('click', () => { + const collapsed = this.sidebar.dataset.collapsed === 'true'; + if (collapsed) { + this.expandSidebar(); + } else { + this.collapseSidebar(); + } + }); + } + + collapseSidebar() { + if (!this.sidebar) return; + + this.sidebar.dataset.collapsed = 'true'; + this.sidebar.classList.remove('w-80'); + this.sidebar.classList.add('w-16'); + + if (this.sidebarCollapseIcon) this.sidebarCollapseIcon.classList.add('hidden'); + if (this.sidebarExpandIcon) this.sidebarExpandIcon.classList.remove('hidden'); + if (this.sidebarExpandedContent) this.sidebarExpandedContent.classList.add('hidden'); + if (this.sidebarCollapsedContent) this.sidebarCollapsedContent.classList.remove('hidden'); + if (this.sidebarTitle) this.sidebarTitle.classList.add('hidden'); + + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, 'true'); + this.resizeChartsAfterTransition(); + } + + expandSidebar() { + if (!this.sidebar) return; + + this.sidebar.dataset.collapsed = 'false'; + this.sidebar.classList.remove('w-16'); + this.sidebar.classList.add('w-80'); + + if (this.sidebarCollapseIcon) this.sidebarCollapseIcon.classList.remove('hidden'); + if (this.sidebarExpandIcon) this.sidebarExpandIcon.classList.add('hidden'); + if (this.sidebarExpandedContent) this.sidebarExpandedContent.classList.remove('hidden'); + if (this.sidebarCollapsedContent) this.sidebarCollapsedContent.classList.add('hidden'); + if (this.sidebarTitle) this.sidebarTitle.classList.remove('hidden'); + + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, 'false'); + this.resizeChartsAfterTransition(); + } + + 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 data service + this.dataService = new MetricsDataService(this.currentProject, { + onMetricUpdate: (metricName, runName, step, value) => { + const chart = this.charts.get(metricName); + if (chart) { + chart.addDataPoint(runName, step, value); + } + }, + onStatusUpdate: (statusData) => this.handleStatusUpdate(statusData), + onCacheUpdated: () => this.renderMetricsFromCache(), + }); + + // 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.loadingState.classList.add('hidden'); + this.chartsContainer.classList.add('hidden'); + this.noDataState.classList.add('hidden'); + this.initialState.classList.remove('hidden'); + this.runSelector.hideAllLegends(); + } + + showLoadingState() { + this.loadingState.classList.remove('hidden'); + this.chartsContainer.classList.add('hidden'); + this.noDataState.classList.add('hidden'); + this.initialState.classList.add('hidden'); + } + + async showMetrics() { + const selectedRuns = this.runSelector.getSelectedRuns(); + + if (selectedRuns.size === 0) { + this.showInitialState(); + this.dataService.closeSSE(); + return; + } + + 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) { + console.error('Error loading metrics:', error); + this.showErrorState(error.message); + return; + } + } + + this.renderMetricsFromCache(); + } + + renderMetricsFromCache() { + const selectedRuns = this.runSelector.getSelectedRuns(); + const metricsData = this.dataService.getCachedMetrics(selectedRuns); + + this.loadingState.classList.add('hidden'); + this.initialState.classList.add('hidden'); + + if (!this.clearAndRenderCharts(metricsData)) { + this.noDataState.classList.remove('hidden'); + this.chartsContainer.classList.add('hidden'); + this.hideChartControls(); + return; + } + + this.noDataState.classList.add('hidden'); + this.chartsContainer.classList.remove('hidden'); + 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.loadingState.classList.add('hidden'); + this.chartsContainer.classList.add('hidden'); + this.initialState.classList.add('hidden'); + this.noDataState.classList.remove('hidden'); + + 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]'); + } +} + +// Initialize when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + new ProjectDetail(); +}); + +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..48238eac84c95bbace51d7f6df6a6ef3520bf0b0 --- /dev/null +++ b/src/aspara/dashboard/static/js/pages/run-detail.js @@ -0,0 +1,88 @@ +/** + * Run detail page entry point. + * Orchestrates metric chart rendering for a single run with grid layout. + */ +import { Chart } from '../chart.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.'; + + this.init(); + this.initializeTagEditor(); + this.initializeNoteEditor(); + this.loadMetrics(); + } + + initializeTagEditor() { + 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`; + }); + } + + initializeNoteEditor() { + 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; + } +} + +// 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; + } + + new RunDetail(project, run); +}); + +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..940eead414277b63ff4d34fc16dd04952b0c6b92 --- /dev/null +++ b/src/aspara/dashboard/static/js/projects-list.js @@ -0,0 +1,383 @@ +import { deleteProjectApi } from './api/delete-api.js'; +import { createSortComparator, matchesSearch, parseProjectElement } from './projects-list-utils.js'; +import { initializeTagEditorsForElements } from './tag-editor.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 timeout (stored for cleanup) + this.searchInputHandler = null; + this.searchTimeoutId = 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) { + 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^="project-tags-"]'); + + // Only navigate if not clicking on interactive elements + if (!isButton && !isTagEditor && !isTagContainer) { + this.navigateToProject(card); + } + }); + + // Keyboard navigation: Enter or Space to activate + card.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.navigateToProject(card); + } + }); + } + } + + /** + * 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'; + } + + // Store handler for cleanup + this.searchInputHandler = (event) => { + const value = event.target.value; + if (this.searchTimeoutId) { + clearTimeout(this.searchTimeoutId); + } + this.searchTimeoutId = setTimeout(() => { + this.currentQuery = value.trim(); + this.sortAndRender(); + }, 300); + }; + 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() { + const headers = document.querySelectorAll('[data-sort]'); + for (const header of headers) { + header.addEventListener('click', () => { + const key = header.dataset.sort; + if (this.sortKey === key) { + this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; + } else { + this.sortKey = key; + this.sortOrder = 'asc'; + } + localStorage.setItem('projects_sort_key', this.sortKey); + localStorage.setItem('projects_sort_order', this.sortOrder); + this.updateSortIndicators(); + this.sortAndRender(); + }); + } + } + + 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; + + container.addEventListener('click', 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); + }); + } + + /** + * 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.searchTimeoutId) { + clearTimeout(this.searchTimeoutId); + this.searchTimeoutId = null; + } + if (this.searchInputHandler) { + const searchInput = document.getElementById('projectSearchInput'); + if (searchInput) { + searchInput.removeEventListener('input', this.searchInputHandler); + } + this.searchInputHandler = null; + } + } +} + +document.addEventListener('DOMContentLoaded', () => { + if (document.getElementById('projects-container')) { + new ProjectsListSorter(); + } +}); + +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..8b59fbfb92fb6fc75811d1ea49a9451116464678 --- /dev/null +++ b/src/aspara/dashboard/static/js/runs-list/index.js @@ -0,0 +1,182 @@ +import { deleteRunApi } from '../api/delete-api.js'; +import { initializeTagEditorsForElements } from '../tag-editor.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.init(); + } + + init() { + this.loadRuns(); + this.attachEventListeners(); + this.updateSortIndicators(); + this.sortAndRender(); + this.initializeTagEditors(); + this.initializeCardNavigation(); + this.initializeDeleteHandlers(); + } + + /** + * 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 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); + } + }); + + // Keyboard navigation: Enter or Space to activate + card.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.navigateToRun(card); + } + }); + } + } + + /** + * 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() { + const headers = document.querySelectorAll('[data-sort]'); + for (const header of headers) { + header.addEventListener('click', () => { + const key = header.dataset.sort; + if (this.sortKey === key) { + this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; + } else { + this.sortKey = key; + this.sortOrder = 'asc'; + } + localStorage.setItem('runs_sort_key', this.sortKey); + localStorage.setItem('runs_sort_order', this.sortOrder); + this.updateSortIndicators(); + this.sortAndRender(); + }); + } + } + + 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; + + 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); + }); + } + + /** + * 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')) { + new RunsListSorter(); + } +}); + +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..ad9832cfa15bd484a7ce35012948f8599e7d2b35 --- /dev/null +++ b/src/aspara/dashboard/static/js/runs-list/sse.js @@ -0,0 +1,135 @@ +/** + * SSE Status Updates for Runs List Page + * Handles real-time status updates for runs displayed in the runs list + */ + +import { INITIAL_SINCE_TIMESTAMP, buildSSEUrl, extractRunNamesFromElements, isConnectionClosed, parseStatusUpdate, updateRunStatusIcon } from './sse-utils.js'; + +class RunsListSSE { + constructor(project) { + this.project = project; + this.eventSource = null; + this.lastTimestamp = INITIAL_SINCE_TIMESTAMP; + this.runs = []; + + // 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); + // 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); + // 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; + } + // 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 + */ + reconnect() { + // Close existing connection if any + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + + // Delay reconnection slightly to avoid rapid reconnection loops + setTimeout(() => { + if (this.runs.length > 0) { + console.log('[RunsListSSE] Reconnecting SSE with since:', this.lastTimestamp); + this.setupSSE(); + } + }, 1000); + } + + handleStatusUpdate(statusData) { + updateRunStatusIcon(statusData, '[RunsListSSE]'); + } + + close() { + if (this.eventSource) { + // Remove event listeners before closing to prevent memory leaks + if (this.sseOpenHandler) { + this.eventSource.removeEventListener('open', this.sseOpenHandler); + } + if (this.sseStatusHandler) { + this.eventSource.removeEventListener('status', this.sseStatusHandler); + } + if (this.sseMetricHandler) { + this.eventSource.removeEventListener('metric', this.sseMetricHandler); + } + if (this.sseErrorHandler) { + this.eventSource.removeEventListener('error', this.sseErrorHandler); + } + this.eventSource.close(); + this.eventSource = null; + console.log('[RunsListSSE] SSE connection closed manually'); + } + this.sseOpenHandler = null; + this.sseStatusHandler = null; + this.sseMetricHandler = null; + this.sseErrorHandler = null; + } +} + +window.RunsListSSE = 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..36215a407f757b317e28d43f3e84634bf192f10a --- /dev/null +++ b/src/aspara/dashboard/static/js/settings-menu.js @@ -0,0 +1,155 @@ +/** + * Settings Menu JavaScript module + * Handles hamburger menu interactions and settings toggles + */ + +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', () => { + new SettingsMenu(); +}); + +export { SettingsMenu }; 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..2fede903d93875c2006a972bac8db317dcab0af9 --- /dev/null +++ b/src/aspara/dashboard/static/js/tag-editor.js @@ -0,0 +1,338 @@ +/** + * Tag editor using @jcubic/tagger library + */ + +import tagger from '@jcubic/tagger'; +import { ICON_EDIT, escapeHtml } from './html-utils.js'; +import { guardReadOnly } from './read-only-guard.js'; + +class TagEditor { + constructor() { + this.taggerInstance = null; + this.isEditing = false; + } + + /** + * 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; + + 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 = + tags.length > 0 + ? tags + .map( + (tag) => + `${escapeHtml(tag)}` + ) + .join('') + : '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) { + // 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(); + }); + + // Edit button toggles edit mode + editBtn.addEventListener('click', (e) => { + e.stopPropagation(); + // Save current tags before entering edit mode + tagsBeforeEdit = input.value + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + this.toggleEditMode(wrapper, input); + }); + + // 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); + }); + + // 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); + }); + + // 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); + } + }, + true + ); // 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; + display.classList.add('hidden'); + 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'); + + errorDiv.classList.add('hidden'); + edit.classList.add('hidden'); + 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 = input.value + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + + try { + const response = await fetch(apiEndpoint, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ tags }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || `Server error: ${response.status}`); + } + + const updatedMetadata = await response.json(); + + // 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 = tags + .map( + (tag) => + `${escapeHtml(tag)}` + ) + .join(''); + } else { + tagList.innerHTML = 'No tags'; + } + } + + /** + * Show error message + */ + showError(wrapper, message) { + const errorDiv = wrapper.querySelector('.tag-error'); + errorDiv.textContent = message; + errorDiv.classList.remove('hidden'); + } +} + +/** + * 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) + */ +function initializeTagEditorsForElements(selector, getApiEndpoint) { + const tagContainers = document.querySelectorAll(selector); + 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); + } +} + +// Export for use in other scripts +export { TagEditor, initializeTagEditorsForElements }; +window.TagEditor = TagEditor; 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..38a22d711f54a97b3e656e7ab01040145a747736 --- /dev/null +++ b/src/aspara/dashboard/templates/_chart_size_controls.mustache @@ -0,0 +1,6 @@ +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/error_404.mustache b/src/aspara/dashboard/templates/error_404.mustache new file mode 100644 index 0000000000000000000000000000000000000000..3781cce502d0b6c29559215e3f15b80f01bf1e27 --- /dev/null +++ b/src/aspara/dashboard/templates/error_404.mustache @@ -0,0 +1,48 @@ +
+
+
+ + + + + + +

404

+ + +

{{error_title}}

+

{{error_message}}

+ + + {{#has_suggestions}} +
+

Suggestions:

+
    + {{#suggestions}} +
  • + + {{{.}}} +
  • + {{/suggestions}} +
+
+ {{/has_suggestions}} + + +
+ + + + + Go Home + + +
+
+
+
diff --git a/src/aspara/dashboard/templates/layout.mustache b/src/aspara/dashboard/templates/layout.mustache new file mode 100644 index 0000000000000000000000000000000000000000..6e3315e2f562f57c44ce7a7022f19005acfc9928 --- /dev/null +++ b/src/aspara/dashboard/templates/layout.mustache @@ -0,0 +1,106 @@ + + + + + + {{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..2650a304d4c953d168f053e64b441f6317cdcae6 --- /dev/null +++ b/src/aspara/dashboard/templates/project_detail.mustache @@ -0,0 +1,182 @@ +
+ +
+
+
+

{{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. Create some runs first to view metrics.

+
+ {{/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..4880000c5e406fdb4a2a42f27c65f11e8cfc9b44 --- /dev/null +++ b/src/aspara/dashboard/templates/projects_list.mustache @@ -0,0 +1,78 @@ +
+ +
+

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. Get started by creating a new project.

+
+ {{/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..dd77dee28653f58c5194e4a7c85559a2d0eae7d1 --- /dev/null +++ b/src/aspara/dashboard/templates/run_detail.mustache @@ -0,0 +1,130 @@ +
+ +
+ +
+
+

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

+

Project: {{project}}

+ {{#is_corrupted}} +

{{error_message}}

+ {{/is_corrupted}} +
+ +
+
+ Start Time: + {{formatted_start_time}} +
+
+ Duration: + {{duration}} +
+
+ Status: + + {{#is_corrupted}} + Error + {{/is_corrupted}} + {{^is_corrupted}} + Completed + {{/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..b51d91e7aa5e75a76399b5be1b8cbd04ba308ce5 --- /dev/null +++ b/src/aspara/dashboard/templates/runs_list.mustache @@ -0,0 +1,105 @@ +
+ +
+
+

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. Get started by creating a new run.

+
+ {{/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..fc27a35b26cb40de1bb03f0ffbbbdbc42760b9ce --- /dev/null +++ b/src/aspara/dashboard/utils/__init__.py @@ -0,0 +1,11 @@ +"""Dashboard utility functions.""" + +from .compression import ( + compress_metrics, + delta_compress, +) + +__all__ = [ + "compress_metrics", + "delta_compress", +] 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/exceptions.py b/src/aspara/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..cd7f002a18f6587216c32daab41f6cdc597c2172 --- /dev/null +++ b/src/aspara/exceptions.py @@ -0,0 +1,17 @@ +""" +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 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..2adc330666c5c6edf706f7255cc436137f5fbebe --- /dev/null +++ b/src/aspara/run/_api.py @@ -0,0 +1,147 @@ +"""Module-level API for run management.""" + +from __future__ import annotations + +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 + + +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, +) -> 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: List of tags for this run. + 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. + + 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") + """ + global _current_run, _storage_backend + + # 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, + ) + _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.") + + _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 + + 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. + """ + 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..ba0aaf941d1e99839aa12a183daee7358f2cadf7 --- /dev/null +++ b/src/aspara/run/_base_run.py @@ -0,0 +1,262 @@ +"""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. + """ + + if step is not None: + self._current_step = step + # When step is None and previous step was committed, we keep the + # current value here and only increment after logging. + 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..955a17b59374e549668ccf300761e4d2b5b35b53 --- /dev/null +++ b/src/aspara/run/_local_run.py @@ -0,0 +1,285 @@ +"""LocalRun implementation for tracking metrics to the local filesystem.""" + +from __future__ import annotations + +import os +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from aspara.config import get_data_dir +from aspara.logger import logger +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 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, + ) -> 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. + """ + super().__init__(name=name, project=project, tags=tags, notes=notes) + + # LocalRun generates its own run_id + self.id = self._generate_run_id() + + self.config = Config(config, on_change=self._on_config_change) + + # 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, + ) + + 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) + + # Log initialization message + backend_msg = f" (backend: {self._storage_backend_type})" if self._storage_backend_type == "polars" else "" + logger.info(f"Run {self.name} initialized{backend_msg}") + + # Log storage location based on backend + if self._storage_backend_type == "polars": + wal_path = os.path.join(base_dir, f"{self.name}.wal.jsonl") + logger.info(f"Writing metrics to: {os.path.abspath(wal_path)}") + else: + logger.info(f"Writing logs to: {os.path.abspath(self._output_path)}") + + self._ensure_output_dir() + self._write_init_record() + + def _write_init_record(self) -> None: + """Write initial run record with metadata.""" + timestamp = int(datetime.now(timezone.utc).timestamp() * 1000) + 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 = int(datetime.now(timezone.utc).timestamp() * 1000) + 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) + try: + shutil.copy2(abs_file_path, dest_path) + except Exception as e: # noqa: BLE001 + raise OSError(f"Failed to copy artifact file: {e}") from e + + # Get file size + file_size = os.path.getsize(dest_path) + + # 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": int(datetime.now(timezone.utc).timestamp() * 1000), + } + + 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) -> None: + """Finish the run and write final record. + + Args: + exit_code: Exit code for the run (0 = success) + quiet: If True, suppress output messages + """ + if not self._mark_finished(): + return + + timestamp = int(datetime.now(timezone.utc).timestamp() * 1000) + 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: + logger.info(f"Run {self.name} finished with exit code {exit_code}") + + def flush(self) -> None: + """Ensure all metrics are written to disk.""" + # Currently a no-op as we're writing directly to file + # This method exists for API compatibility and future buffering support + pass + + 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..f977031d55c0ba3921c402fdd5fd0b05de1798a1 --- /dev/null +++ b/src/aspara/run/_offline_queue.py @@ -0,0 +1,591 @@ +"""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 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.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 + if not self._meta_file.exists(): + metadata = QueueMetadata( + tracker_uri=self.tracker_uri, + project=self.project, + run_name=self.run_name, + run_id=self.run_id, + ) + self._meta_file.write_text(metadata.model_dump_json(indent=2)) + + # 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 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") + 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:] + with self._queue_file.open("w") as f: + f.writelines(remaining) + + self._item_count = len(remaining) + except OSError as e: + logger.warning(f"Failed to drop oldest items from queue: {e}") + + def get_ready_items(self, limit: int = 100) -> list[MetricsQueueItem]: + """Get items ready for retry, sorted by step. + + Args: + limit: Maximum number of items to return + + Returns: + List of items ready for retry + """ + now_ms = int(time.time() * 1000) + items: list[MetricsQueueItem] = [] + + with self._lock: + if not self._queue_file.exists(): + return items + + try: + with self._queue_file.open("r") as f: + for line in f: + if not line.strip(): + continue + try: + item = MetricsQueueItem.from_jsonl(line.strip()) + if item.next_retry_at <= now_ms: + items.append(item) + except (ValueError, ValidationError) as e: + logger.debug(f"Skipping invalid queue item: {e}") + continue + + if len(items) >= limit: + break + except OSError: + pass + + # Sort by step to maintain order + items.sort(key=lambda x: (x.step, x.created_at)) + return items[:limit] + + 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 + try: + item = MetricsQueueItem.from_jsonl(line.strip()) + if item.id in ids_set: + removed += 1 + continue + except (ValueError, ValidationError) as e: + logger.debug(f"Skipping invalid queue item during dequeue: {e}") + remaining_lines.append(line if line.endswith("\n") else line + "\n") + + with self._queue_file.open("w") as f: + f.writelines(remaining_lines) + + 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 + try: + item = MetricsQueueItem.from_jsonl(line.strip()) + if 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 + except (ValueError, ValidationError) as e: + logger.debug(f"Skipping invalid queue item during retry update: {e}") + new_lines.append(line if line.endswith("\n") else line + "\n") + + if updated: + with self._queue_file.open("w") as f: + f.writelines(new_lines) + + 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: + pass + + +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..5218b420aa580d53d2635a4a89992bc0d8b1609c --- /dev/null +++ b/src/aspara/run/_remote_run.py @@ -0,0 +1,471 @@ +"""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 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, + ) -> 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 + + 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, + }, + 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 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 Exception: + 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, + ) -> 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) + + 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 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, + ) + # 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 Exception 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 Exception 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 Exception 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() + + logger.info(f"RemoteRun {self.name} initialized") + logger.info(f"Sending metrics to: {tracker_uri}") + + 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 Exception 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 Exception as e: + logger.warning(f"Failed to finish run on tracker: {e}") + + if not quiet: + logger.info(f"RemoteRun {self.name} finished with exit code {exit_code}") + + 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 Exception 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. + + Note: + This method is not yet supported for remote runs. + + Raises: + NotImplementedError: Always raised as remote tag setting is not implemented. + """ + raise NotImplementedError("set_tags is not yet supported for remote runs") 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..2922f9f46b29563cd38f3288e2647c0e44042b99 --- /dev/null +++ b/src/aspara/run/run.py @@ -0,0 +1,211 @@ +"""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, + ) -> 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. + """ + 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, + ) + 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, + ) + + # ---- 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) -> None: + """Finish the run. + + Args: + exit_code: Exit code for the run (0 = success) + quiet: If True, suppress output messages + """ + self._backend.finish(exit_code=exit_code, quiet=quiet) + + def flush(self, *args: Any, **kwargs: Any) -> Any: + """Ensure all data is persisted. + + For LocalRun this is a no-op. For RemoteRun this flushes queued metrics. + """ + return self._backend.flush(*args, **kwargs) + + 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. + + Args: + tags: List of tags + + Note: + This method is only available for local runs. + """ + 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..3ba6242c808b25ffc166d378662075bfaeddf92d --- /dev/null +++ b/src/aspara/server/_app.py @@ -0,0 +1,185 @@ +""" +Aspara main application +""" + +import importlib.util +import os +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse + +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 +) + +# CORS configuration +app.add_middleware( + CORSMiddleware, # ty: ignore[invalid-argument-type] + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +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 + + +@app.get("/docs", response_class=HTMLResponse) +async def docs_page(request: Request) -> HTMLResponse: + """Root endpoint - displays links to documentation + + 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/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..acfdea8b13079cd837bd8f1a777544defb78dcd0 --- /dev/null +++ b/src/aspara/storage/metadata/project.py @@ -0,0 +1,170 @@ +""" +ProjectMetadataStorage - Project-level metadata storage. + +This module provides storage for project metadata (notes, tags, timestamps) +used by the catalog and dashboard layers. +""" + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +from aspara.utils import atomic_write_json +from aspara.utils.validators import validate_name, validate_safe_path + +from .models import validate_metadata + + +class ProjectMetadataStorage: + """Project-level metadata storage. + + Stores project metadata in {project_name}/metadata.json files. + This includes notes, tags, created_at, and updated_at timestamps. + """ + + 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] = { + "notes": "", + "tags": [], + "created_at": None, + "updated_at": None, + } + 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 _load(self) -> None: + """Load existing metadata from file if it exists. + + Uses try/except pattern instead of exists() check to avoid TOCTOU race condition. + """ + try: + with open(self._metadata_path, encoding="utf-8") as f: + loaded_metadata = json.load(f) + + self._metadata = { + "notes": loaded_metadata.get("notes", ""), + "tags": loaded_metadata.get("tags", []), + "created_at": loaded_metadata.get("created_at"), + "updated_at": loaded_metadata.get("updated_at"), + } + except FileNotFoundError: + # File doesn't exist yet, keep default values + pass + except (json.JSONDecodeError, OSError): + # File is corrupted or unreadable, keep default values + pass + + def _save(self) -> None: + """Save metadata to file. + + Raises: + ValueError: If failed to write metadata file + """ + 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. + + Args: + metadata: Metadata dictionary to validate + + Raises: + ValueError: If validation fails + """ + validate_metadata(metadata) + + def get_metadata(self) -> dict[str, Any]: + """Get all metadata. + + Returns: + Complete metadata dictionary with notes, tags, created_at, updated_at + """ + return dict(self._metadata) + + 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().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 + """ + if not self._metadata_path.exists(): + return False + + try: + self._metadata_path.unlink() + self._metadata = { + "notes": "", + "tags": [], + "created_at": None, + "updated_at": None, + } + return True + except OSError: + return False + + def close(self) -> None: + """Close storage (no-op for file-based storage).""" + pass diff --git a/src/aspara/storage/metadata/run.py b/src/aspara/storage/metadata/run.py new file mode 100644 index 0000000000000000000000000000000000000000..57d9353d5d0f469868277ec3ed11d24983a05694 --- /dev/null +++ b/src/aspara/storage/metadata/run.py @@ -0,0 +1,316 @@ +""" +RunMetadataStorage - Run-level metadata storage. + +This module provides storage for run metadata (params, config, tags, notes, etc.) +used for experiment tracking. +""" + +import json +from pathlib import Path +from typing import Any + +from aspara.models import RunStatus +from aspara.utils import atomic_write_json +from aspara.utils.validators import validate_name, validate_safe_path + +from .models import validate_metadata + + +class RunMetadataStorage: + """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 _load(self) -> None: + """Load existing metadata from file if it exists. + + Uses try/except pattern instead of exists() check to avoid TOCTOU race condition. + """ + try: + with open(self._metadata_path, encoding="utf-8") as f: + self._metadata = json.load(f) + except FileNotFoundError: + # File doesn't exist yet, keep default values + pass + except (json.JSONDecodeError, OSError): + # File is corrupted or unreadable, keep default values + pass + + def _save(self) -> None: + """Save metadata to file. + + Raises: + ValueError: If failed to write metadata file + """ + 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. + + Args: + metadata: Metadata dictionary to validate + + Raises: + ValueError: If validation fails + """ + validate_metadata(metadata) + + 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 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 get_metadata(self) -> dict[str, Any]: + """Get all metadata. + + Returns: + Complete metadata dictionary + """ + return dict(self._metadata) + + 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 + """ + if not self._metadata_path.exists(): + return False + self._metadata_path.unlink() + 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) + + def close(self) -> None: + """Close storage (no-op for file-based storage).""" + pass 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..a7b093f3c164a56e657307c18ce400ad96a2d116 --- /dev/null +++ b/src/aspara/storage/metrics/jsonl.py @@ -0,0 +1,158 @@ +""" +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: + # 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 + # (excluding timestamp and step) + metric_cols = [col for col in schema.names() if col not in ["timestamp", "step", "project_name", "run_name"]] + 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..f06be37e3f026c9c4c74c0419edbcceee2dd371f --- /dev/null +++ b/src/aspara/storage/metrics/polars.py @@ -0,0 +1,447 @@ +""" +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 +from datetime import datetime +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 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 = [] + if wal_path.exists(): + 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 + 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) + 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 by truncating. + + Args: + wal_path: Path to the WAL file + """ + with open(wal_path, "w") as f: + f.truncate(0) + f.flush() + datasync(f.fileno()) + + 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 _try_archive(self) -> bool: + """Try to archive WAL to Parquet via Polars. + + Returns: + bool: True if archive succeeded + """ + wal_path = self._get_wal_path() + + 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 + + # Write and clear WAL + self._write_partitioned_parquet(combined_df, archive_path) + self._clear_wal(wal_path) + 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 + if wal_path.exists() and wal_path.stat().st_size >= self._archive_threshold: + 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() + if wal_path.exists() and wal_path.stat().st_size > 0: + 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..8b6387c12eba0687895b64c2734337a0572162e9 --- /dev/null +++ b/src/aspara/tracker/main.py @@ -0,0 +1,18 @@ +"""Aspara Tracker API application. + +FastAPI application for the tracker API. +""" + +from fastapi import FastAPI + +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", + redoc_url="/docs/tracker/redoc", +) + +app.include_router(router) diff --git a/src/aspara/tracker/models.py b/src/aspara/tracker/models.py new file mode 100644 index 0000000000000000000000000000000000000000..997428ba06f4effe7b26d376b1a4142b80c0be96 --- /dev/null +++ b/src/aspara/tracker/models.py @@ -0,0 +1,88 @@ +""" +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 + + +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 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..3a4b9215a46ed7d304d268fecd560a7afed47eac --- /dev/null +++ b/src/aspara/tracker/router.py @@ -0,0 +1,497 @@ +"""Aspara Tracker API router. + +RESTful API endpoints using FastAPI APIRouter. +""" + +import logging +import os +import shutil +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, 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, +) + +logger = logging.getLogger(__name__) + +# Maximum artifact file size (100MB) +MAX_ARTIFACT_SIZE = 100 * 1024 * 1024 + +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 duplicate run by checking existing metadata file + metadata_path = base_dir / project_name / f"{request.name}.meta.json" + if metadata_path.exists(): + raise HTTPException(status_code=409, detail="Run already exists") + + # 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 + + # Check file size limit + if file.size and file.size > MAX_ARTIFACT_SIZE: + raise HTTPException( + status_code=413, + detail=f"File too large: {file.size} bytes (max: {MAX_ARTIFACT_SIZE} bytes)", + ) + + # 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 open(dest_path, "wb") as f: + shutil.copyfileobj(file.file, f) + + logger.info(f"Uploaded artifact: {artifact_name} to {project_name}/{run_name}") + + # Get file size + file_size = os.path.getsize(dest_path) + + # 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 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..a9d9b8862ca601889a42ad1e1552517d4b2a3da7 --- /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=True), + ] + + 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..f3020548e6b3917c8c56a7db2bedd7978a343831 --- /dev/null +++ b/src/aspara/tui/screens/help.py @@ -0,0 +1,86 @@ +""" +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[/] Go back / Close modal + +[bold underline]Navigation (vim-style)[/bold underline] + [cyan]j[/] / [cyan]↓[/] Move down + [cyan]k[/] / [cyan]↑[/] Move up + [cyan]Enter[/] Select / Confirm + [cyan]/[/] Focus search input + [cyan]s[/] Toggle sort order + +[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]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..3752e9df0c676f7558170779ecad84350d2179b1 --- /dev/null +++ b/src/aspara/tui/screens/metric_chart.py @@ -0,0 +1,326 @@ +""" +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("backspace", "go_back", "Back", show=False), + ] + + 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_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..ae3c5a4a2c8c6e79703ed54b0afa46b212af7300 --- /dev/null +++ b/src/aspara/tui/screens/projects.py @@ -0,0 +1,222 @@ +""" +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("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") + self._projects = [] + except PermissionError as e: + logger.warning("Permission denied loading projects: %s", e) + self.notify("Permission denied", severity="error") + self._projects = [] + except OSError as e: + logger.error("Failed to load projects: %s", e) + self.notify("Failed to load projects", 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, + ) + + 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_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..bb1b0a50635973dda69cd9cf7dde61fc753bf9dc --- /dev/null +++ b/src/aspara/tui/screens/run_detail.py @@ -0,0 +1,222 @@ +""" +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=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 (Click 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("Failed to load run info", 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("Failed to load metrics", 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_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..89b94f1d4d0928a9e54f35ef0c96146964a9257c --- /dev/null +++ b/src/aspara/tui/screens/runs.py @@ -0,0 +1,246 @@ +""" +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=False), + 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") + self._runs = [] + except PermissionError as e: + logger.warning("Permission denied loading runs: %s", e) + self.notify("Permission denied", severity="error") + self._runs = [] + except OSError as e: + logger.error("Failed to load runs: %s", e) + self.notify("Failed to load runs", 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_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..d47349d2131da7cf59545a804a1e9b6485c54cf4 --- /dev/null +++ b/src/aspara/utils/__init__.py @@ -0,0 +1,24 @@ +"""Utility modules for aspara.""" + +from aspara.utils.file import atomic_write_json, 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", + "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..af938a61d65300afe0ac8eb49cf61758631cbd7f --- /dev/null +++ b/src/aspara/utils/file.py @@ -0,0 +1,117 @@ +"""File operation utilities for aspara.""" + +import contextlib +import json +import os +import platform +import stat +import tempfile +from collections.abc import 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_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 + """ + target_path = Path(path) + target_dir = target_path.parent + + # Ensure target directory exists + target_dir.mkdir(parents=True, exist_ok=True) + + # Use tempfile.NamedTemporaryFile for secure temp file creation + # - Creates file with O_EXCL flag (prevents symlink attacks) + # - Creates in same directory as target (allows atomic rename) + # - delete=False because we want to rename it, not delete it + tmp_fd = None + tmp_path = None + try: + tmp_fd, tmp_name = tempfile.mkstemp( + suffix=".json", + 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 + json.dump(data, f, ensure_ascii=False, indent=2) + 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() 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..194d6f180020a8b7ef3268142c3244a98ac53d34 --- /dev/null +++ b/src/aspara/utils/timestamp.py @@ -0,0 +1,85 @@ +"""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 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..dee06d84386843819eddbf64cacee1f90f38e6ab --- /dev/null +++ b/src/aspara/utils/validators.py @@ -0,0 +1,161 @@ +"""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. It also checks for + symlinks in the path to prevent symlink-based attacks. + + Args: + path: Path to validate + base_dir: Base directory that should contain the path + + Raises: + ValueError: If path is outside base_dir, contains symlinks, 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: + # First resolve both paths to absolute paths + resolved_path = path.resolve() + resolved_base = base_dir.resolve() + + # Check for symlinks in the path hierarchy + # Walk from the path up to the base directory, checking each component + current = path + while current != current.parent: + if current.exists() and current.is_symlink(): + raise ValueError(f"Path contains symlink: {current}") + # Stop checking when we reach the base directory + try: + current.relative_to(resolved_base) + except ValueError: + # We've gone above the base directory, stop checking + break + current = current.parent + + # 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..4310e9f2fd1b65ba2127275f39de916bfec4852f --- /dev/null +++ b/uv.lock @@ -0,0 +1,1982 @@ +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 = "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 = "textual" }, + { name = "textual-plotext" }, + { name = "uvicorn" }, + { name = "watchfiles" }, +] +dashboard = [ + { name = "aiofiles" }, + { name = "fastapi" }, + { name = "lttb" }, + { name = "msgpack" }, + { name = "numpy" }, + { name = "pystache" }, + { name = "sse-starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] +remote = [ + { name = "requests" }, +] +tracker = [ + { name = "fastapi" }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] +tui = [ + { name = "textual" }, + { name = "textual-plotext" }, +] + +[package.dev-dependencies] +dev = [ + { name = "aspara", extra = ["all"] }, + { name = "bandit" }, + { name = "httpx" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "mypy" }, + { 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 = "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.0.0" }, + { 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.22" }, + { name = "requests", marker = "extra == 'remote'", specifier = ">=2.31.0" }, + { name = "sse-starlette", marker = "extra == 'dashboard'", specifier = ">=1.8.0" }, + { name = "textual", marker = "extra == 'tui'", specifier = ">=0.47.0" }, + { name = "textual-plotext", marker = "extra == 'tui'", specifier = ">=0.2.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 = "bandit", specifier = ">=1.9.3" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "mkdocs", specifier = ">=1.6.0" }, + { name = "mkdocs-autorefs", specifier = ">=0.5.0" }, + { name = "mkdocs-material", specifier = ">=9.6.0" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.24.0" }, + { name = "mypy", specifier = ">=1.19.1" }, + { name = "playwright", specifier = ">=1.52.0" }, + { name = "py-spy", specifier = ">=0.4.1" }, + { name = "pyrefly", specifier = ">=0.46.1" }, + { name = "pytest", specifier = ">=8.3.4" }, + { 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 = "bandit" +version = "1.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/76/a7f3e639b78601118aaa4a394db2c66ae2597fbd8c39644c32874ed11e0c/bandit-1.9.3.tar.gz", hash = "sha256:ade4b9b7786f89ef6fc7344a52b34558caec5da74cb90373aed01de88472f774", size = 4242154, upload-time = "2026-01-19T04:05:22.802Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/0b/8bdc52111c83e2dc2f97403dc87c0830b8989d9ae45732b34b686326fb2c/bandit-1.9.3-py3-none-any.whl", hash = "sha256:4745917c88d2246def79748bde5e08b9d5e9b92f877863d43fab70cd8814ce6a", size = 134451, upload-time = "2026-01-19T04:05:20.938Z" }, +] + +[[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.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + +[[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.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[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 = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/13/57b06758a13550c5f09563893b004f98e9537ee6ec67b7df85c3571c8832/librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d", size = 56521, upload-time = "2026-01-14T12:54:40.066Z" }, + { url = "https://files.pythonhosted.org/packages/c2/24/bbea34d1452a10612fb45ac8356f95351ba40c2517e429602160a49d1fd0/librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b", size = 58456, upload-time = "2026-01-14T12:54:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/04/72/a168808f92253ec3a810beb1eceebc465701197dbc7e865a1c9ceb3c22c7/librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d", size = 164392, upload-time = "2026-01-14T12:54:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/14/5c/4c0d406f1b02735c2e7af8ff1ff03a6577b1369b91aa934a9fa2cc42c7ce/librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d", size = 172959, upload-time = "2026-01-14T12:54:44.602Z" }, + { url = "https://files.pythonhosted.org/packages/82/5f/3e85351c523f73ad8d938989e9a58c7f59fb9c17f761b9981b43f0025ce7/librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c", size = 186717, upload-time = "2026-01-14T12:54:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/08/f8/18bfe092e402d00fe00d33aa1e01dda1bd583ca100b393b4373847eade6d/librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c", size = 184585, upload-time = "2026-01-14T12:54:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fc/f43972ff56fd790a9fa55028a52ccea1875100edbb856b705bd393b601e3/librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d", size = 180497, upload-time = "2026-01-14T12:54:48.946Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3a/25e36030315a410d3ad0b7d0f19f5f188e88d1613d7d3fd8150523ea1093/librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0", size = 200052, upload-time = "2026-01-14T12:54:50.382Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b8/f3a5a1931ae2a6ad92bf6893b9ef44325b88641d58723529e2c2935e8abe/librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85", size = 43477, upload-time = "2026-01-14T12:54:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/fe/91/c4202779366bc19f871b4ad25db10fcfa1e313c7893feb942f32668e8597/librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c", size = 49806, upload-time = "2026-01-14T12:54:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + +[[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.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" }, + { url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "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.0.2" +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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[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.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[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 = "0.50.0" +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/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "stevedore" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/5b/496f8abebd10c3301129abba7ddafd46c71d799a70c44ab080323987c4c9/stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945", size = 516074, upload-time = "2025-11-20T10:06:07.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/40/8561ce06dc46fd17242c7724ab25b257a2ac1b35f4ebf551b40ce6105cfa/stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", size = 54428, upload-time = "2025-11-20T10:06:05.946Z" }, +] + +[[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.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.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..ad7aa426ca9e23ed247ecf157822fd01ae6b8abd --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,44 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // テスト環境 + environment: 'jsdom', + + // テストファイルパターン + include: ['**/tests/**/*.test.js'], + + // Playwrightテストを除外 + exclude: ['**/node_modules/**', '**/*.spec.js', '**/e2e/**'], + + // セットアップファイル + setupFiles: ['./tests/vitest-canvas-setup.js', './tests/vitest-setup.js'], + + // グローバル設定(describe, it, expect等をインポート不要にする) + globals: true, + + // カバレッジ設定 + 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, + }, + }, + + // テストタイムアウト + testTimeout: 10000, + + // DOM環境設定 + environmentOptions: { + jsdom: { + url: 'http://localhost', + }, + }, + }, +});