diff --git a/.github/workflows/update-data.yml b/.github/workflows/update-data.yml index a458abeffb..07cced959f 100644 --- a/.github/workflows/update-data.yml +++ b/.github/workflows/update-data.yml @@ -2,7 +2,7 @@ name: Update Dashboard Data on: schedule: - - cron: '0 6 * * *' # Daily at 6am UTC + - cron: "0 6 * * *" # Daily at 6am UTC workflow_dispatch: permissions: @@ -22,7 +22,7 @@ jobs: uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - name: Set up Python - run: uv python install 3.13 + run: uv python install 3.14 - name: Restore bare repo cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 @@ -31,20 +31,8 @@ jobs: key: git-repos-${{ github.run_id }} restore-keys: git-repos- - - name: Fetch GraphQL data - run: uv run scripts/fetch_graphql.py --output-dir data - - - name: Check GitHub mirrors - run: uv run scripts/check_github.py --output-dir data - - - name: Check S3 versions - run: uv run scripts/check_s3_version.py --output-dir data - - - name: Check S3 files - run: uv run scripts/check_s3_files.py --output-dir data --cache-dir ~/.cache/openneuro-dashboard/repos - - - name: Generate summary - run: uv run scripts/summarize.py --output-dir data + - name: Run pipeline + run: uv run openneuro-dashboard run-all - name: Commit and push if changed run: | diff --git a/CLAUDE.md b/CLAUDE.md index 326059bf04..37046adca0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,10 +8,10 @@ OpenNeuro Dataset Monitor — a static HTML/JS dashboard with a Python data pipe ## Architecture -**Data Pipeline** (5-stage ETL): +**Data Pipeline** (5-stage ETL, implemented in `code/src/openneuro_dashboard/`): ``` -fetch_graphql.py → check_github.py → check_s3_version.py → check_s3_files.py → summarize.py +fetch-graphql → check-github → check-s3-version → check-s3-files → summarize ``` Each stage reads outputs from previous stages and can be run independently. @@ -20,28 +20,39 @@ Each stage reads outputs from previous stages and can be run independently. **Data**: Pipeline outputs go to `data/` as JSON. The dashboard loads these via fetch. Schema defined in `schema/openneuro-dashboard.yaml` (LinkML, version 1.0.0). -## Running Scripts +## Running the Pipeline -All Python scripts use **`uv`** with inline PEP 723 dependency declarations (no requirements.txt or pyproject.toml). Run with: +The pipeline is an installable Python package with a `openneuro-dashboard` CLI: ```bash -uv run scripts/fetch_graphql.py -uv run scripts/check_github.py -uv run scripts/check_s3_version.py -uv run scripts/check_s3_files.py --cache-dir ~/.cache/openneuro-dashboard/repos +cd code +uv sync +uv run openneuro-dashboard run-all --output-dir ../data ``` -Test data generators: +Individual stages: + +```bash +cd code +uv run openneuro-dashboard fetch-graphql --output-dir ../data +uv run openneuro-dashboard check-github --output-dir ../data +uv run openneuro-dashboard check-s3-version --output-dir ../data +uv run openneuro-dashboard check-s3-files --output-dir ../data --cache-dir ~/.cache/openneuro-dashboard/repos +uv run openneuro-dashboard summarize --output-dir ../data +``` + +Test data generation: + ```bash -uv run scripts/gen_data/graphql.py -uv run scripts/gen_data/github.py -uv run scripts/gen_data/s3_version.py +cd code +uv run openneuro-dashboard gen-data --output-dir ../data --seed 42 ``` -After running either the full or test scripts, aggregate summary data with: +## Running Tests ```bash -uv run scripts/summarize.py +cd code +uv run --group test pytest -v ``` ## Serving the Dashboard @@ -54,7 +65,7 @@ python -m http.server 8000 ## Key Conventions - Dataset IDs match pattern `^ds[0-9]{6}$` -- All output JSON files include `schemaVersion: "1.1.0"` (from `scripts/utils.py:SCHEMA_VERSION`) +- All output JSON files include `schemaVersion: "1.1.0"` (from `code/src/openneuro_dashboard/utils.py:SCHEMA_VERSION`) - Snapshot metadata and file listings are immutable; registry, check results, and summary are mutable -- Scripts use async I/O (asyncio) and include `--validate` flags for data consistency checking -- Python requires >=3.13 +- Pipeline modules use async I/O (asyncio) and include `--validate` flags for data consistency checking +- Python requires >=3.14 diff --git a/README.md b/README.md index ec1142c651..1eb7136796 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,13 @@ A dashboard for tracking synchronization status of OpenNeuro datasets across Gra The monitoring system uses a multi-stage pipeline that generates static JSON files consumed by a client-side dashboard: ``` -GraphQL → GitHub Check → S3 Version → Git Trees → S3 Diff → Summarize → Dashboard +fetch-graphql → check-github → check-s3-version → check-s3-files → summarize ``` Each stage reads from previous stages and writes new check files, allowing incremental updates and independent execution. +The pipeline is implemented as an installable Python package under `code/`, exposing an `openneuro-dashboard` CLI. + ### Data Model All data files (aspirationally) follow a versioned schema defined in `schema/openneuro-dashboard.yaml` (LinkML format). @@ -45,6 +47,7 @@ data/datasets/{id}/ ### Check Logic #### GitHub Check + - Uses `git ls-remote --symref` to fetch all refs - Validates: - All snapshot tags exist on GitHub @@ -52,6 +55,7 @@ data/datasets/{id}/ - Commit SHAs match GraphQL data #### S3 Version Check + - Fetches `dataset_description.json` from S3 - Extracts version from `DatasetDOI` field - **Edge cases**: @@ -64,6 +68,7 @@ data/datasets/{id}/ - All other cases allow file comparison with assumed version #### S3 File Diff + - Compares S3 file listing against git tree - Uses version from `s3-version.json` (either from DOI or assumed latest) - Skipped if S3 is blocked (403) @@ -72,6 +77,7 @@ data/datasets/{id}/ ### Status Values **Per-check statuses**: + - `ok`: Check passed - `warning`: Minor issues (e.g., assumed version, HEAD mismatch) - `error`: Check failed or blocked @@ -79,76 +85,60 @@ data/datasets/{id}/ - `pending`: Check not yet run **Special flags**: -- `s3Blocked: true` in summary indicates 403 error (shows lock icon 🔒) - -## Running the Pipeline -Some scripts declare dependencies in their headers. -The simplest way to run these scripts is `uv run`. +- `s3Blocked: true` in summary indicates 403 error (shows lock icon) -### Stage 1: Fetch GraphQL Data +## Setup ```bash -uv run scripts/fetch_graphql.py --output-dir data +uv sync ``` -Queries OpenNeuro GraphQL API for all public datasets and their snapshots. Creates: -- `datasets-registry.json` -- Per-dataset `snapshots.json` and `snapshots/{tag}/metadata.json` +Requires Python 3.14+. -**Options**: -- `--page-size N`: Datasets per GraphQL page (default: 100) -- `--prefetch N`: Pages to buffer (default: 2) -- `--verbose`: Detailed logging +## Running the Pipeline -### Stage 2: Check GitHub Mirrors +### Full Pipeline ```bash -uv run scripts/check_github.py --output-dir data +uv run openneuro-dashboard run-all ``` -Validates GitHub mirror status for all datasets. - -**Options**: -- `--concurrency N`: Parallel git operations (default: 10) -- `--validate`: Run post-check validation -- `--verbose`: Detailed logging - -### Stage 3: Check S3 Versions +### Individual Stages ```bash -uv run scripts/check_s3_version.py --output-dir data -``` +# Stage 1: Fetch GraphQL data +uv run openneuro-dashboard fetch-graphql -Fetches `dataset_description.json` from S3 and extracts versions. +# Stage 2: Check GitHub mirrors +uv run openneuro-dashboard check-github -**Options**: -- `--concurrency N`: Parallel HTTP requests (default: 20) -- `--validate`: Run post-check validation +# Stage 3: Check S3 versions +uv run openneuro-dashboard check-s3-version -### Stage 4: Fetch Git File Trees +# Stage 4: Check S3 files +uv run openneuro-dashboard check-s3-files --cache-dir ~/.cache/openneuro-dashboard/repos -(Not yet implemented - currently using generated test data) - -Should fetch file listings from git for each snapshot tag: -```bash -git clone --bare --depth=1 --filter=blob:none --branch {tag} {repo} -git ls-files --with-tree {tag} +# Stage 5: Summarize +uv run openneuro-dashboard summarize ``` -### Stage 5: Generate S3 File Diffs +Common options: -(Not yet implemented - currently using generated test data) +- `--verbose` / `-v`: Enable verbose output +- `--max-datasets N`: Limit number of datasets (for `fetch-graphql` and `run-all`) -Should compare S3 file listings against git trees and create `s3-diff.json`. - -### Stage 6: Summarize +### Generating Test Data ```bash -uv run scripts/summarize.py --output-dir data +uv run openneuro-dashboard gen-data --num-datasets 50 --seed 42 ``` -Reads all check files and generates `all-datasets.json` with aggregated results. +## Running Tests + +```bash +uv run --group test pytest -v +``` ## Dashboard @@ -165,7 +155,6 @@ Static HTML/CSS/JS dashboard served from the repository root. ### Serving ```bash -# Python python -m http.server 8000 ``` @@ -174,43 +163,29 @@ Navigate to `http://localhost:8000` ### Features **Main view**: + - Sortable/filterable dataset table - Summary statistics by status - Search by dataset ID - Color-coded status badges -- Lock icons (🔒) for blocked S3 datasets +- Lock icons for blocked S3 datasets **Detail view**: + - Snapshot history - Detailed check results with expandable sections - File diff viewer (when mismatches exist) - Lazy-loaded file listings -## Test Data Generation - -Located in `scripts/gen_data/`, these scripts simulate pipeline stages for development: - -```bash -python scripts/gen_data/graphql.py -python scripts/gen_data/github.py -python scripts/gen_data/s3_version.py -python scripts/gen_data/s3_version.py -``` - -## Development Workflow - -1. **Add real pipeline stage**: Implement stage script (e.g., `fetch_git_trees.py`) -2. **Update test generator**: Modify corresponding `gen_data/*.py` to match -3. **Test incrementally**: Run new stage, then existing summarize + dashboard -4. **Validate**: Use `--validate` flags to check data consistency - ## Data Immutability **Immutable** (never changes once created): + - `snapshots/{tag}/metadata.json` - `snapshots/{tag}/files.json` **Mutable** (updated on each check run): + - `datasets-registry.json` - `github.json` - `s3-version.json` @@ -219,15 +194,6 @@ python scripts/gen_data/s3_version.py This allows caching of snapshot data while keeping check results fresh. -## Future Enhancements - -- [ ] Implement git tree fetching (stage 4) -- [ ] Implement S3 file diff (stage 5) -- [ ] Scripts to auto-fix issues based on outputs -- [ ] Schedule data updates in CI -- [ ] Track historical trends -- [ ] Integration with GitHub issues to track known problems - ## Schema Evolution The LinkML schema (`schema/openneuro-dashboard.yaml`) includes a `schemaVersion` field in all data files. When making breaking changes: diff --git a/code/src/openneuro_dashboard/__init__.py b/code/src/openneuro_dashboard/__init__.py new file mode 100644 index 0000000000..bc488b744d --- /dev/null +++ b/code/src/openneuro_dashboard/__init__.py @@ -0,0 +1,20 @@ +"""OpenNeuro Dashboard data-population tools. + +ondiagnostics modules used +-------------------------- +- ``ondiagnostics.graphql``: GraphQLResponse, PageInfo, create_client, get_page + (pagination over the OpenNeuro GraphQL API in fetch_graphql.py) +- ``ondiagnostics.subprocs``: git + (async subprocess wrapper for bare-clone / fetch in check_s3_files.py) +- ``ondiagnostics.tasks.git``: list_refs + (remote ref listing for GitHub mirror checks in check_github.py) + +Dashboard-specific logic +------------------------ +- fetch_graphql: writes datasets-registry.json and per-snapshot metadata +- check_github: compares registry against GitHub mirror refs +- check_s3_version: extracts S3 export version from dataset_description.json +- check_s3_files: diffs git tree against S3 object listing +- summarize: aggregates per-dataset check results into all-datasets.json +- utils: shared JSON I/O helpers and schema version constant +""" diff --git a/code/src/openneuro_dashboard/__main__.py b/code/src/openneuro_dashboard/__main__.py new file mode 100644 index 0000000000..49480e8b19 --- /dev/null +++ b/code/src/openneuro_dashboard/__main__.py @@ -0,0 +1,203 @@ +"""OpenNeuro Dashboard CLI.""" + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +app = typer.Typer( + name="openneuro-dashboard", + help="Data-population pipeline for the OpenNeuro dashboard.", +) + +OutputDir = Annotated[ + Path, + typer.Option("--output-dir", help="Directory to write output data."), +] +Verbose = Annotated[ + bool, + typer.Option("--verbose", "-v", help="Enable verbose output."), +] + + +@app.command() +def fetch_graphql( + output_dir: OutputDir = Path("data"), + verbose: Verbose = False, + page_size: Annotated[ + int, typer.Option("--page-size", help="Number of datasets per page.") + ] = 100, + prefetch: Annotated[ + int, typer.Option("--prefetch", help="Number of pages to prefetch.") + ] = 2, + dry_run: Annotated[ + bool, typer.Option("--dry-run", help="Run without writing files.") + ] = False, + validate: Annotated[ + bool, typer.Option("--validate", help="Validate fetched data.") + ] = False, + max_datasets: Annotated[ + Optional[int], + typer.Option("--max-datasets", help="Maximum number of datasets to fetch."), + ] = None, +) -> None: + """Fetch dataset metadata from the OpenNeuro GraphQL API.""" + import asyncio + + from .fetch_graphql import fetch_and_write as _fetch_and_write + from .fetch_graphql import validate_output as _validate_output + + asyncio.run( + _fetch_and_write( + output_dir, + page_size, + prefetch, + dry_run, + verbose, + max_datasets, + ) + ) + + if validate: + _validate_output(output_dir) + + +@app.command() +def check_github( + output_dir: OutputDir = Path("data"), + verbose: Verbose = False, + concurrency: Annotated[ + int, typer.Option("--concurrency", help="Number of concurrent requests.") + ] = 10, +) -> None: + """Check GitHub repository status for each dataset.""" + import asyncio + + from .check_github import check_all_datasets as _check_all_datasets + + asyncio.run(_check_all_datasets(output_dir, concurrency, verbose)) + + +@app.command() +def check_s3_version( + output_dir: OutputDir = Path("data"), + verbose: Verbose = False, + concurrency: Annotated[ + int, typer.Option("--concurrency", help="Number of concurrent S3 requests.") + ] = 20, +) -> None: + """Check S3 version consistency for each dataset.""" + import asyncio + + from .check_s3_version import check_all_datasets as _check_all_datasets + + asyncio.run(_check_all_datasets(output_dir, concurrency, verbose)) + + +@app.command() +def check_s3_files( + output_dir: OutputDir = Path("data"), + verbose: Verbose = False, + cache_dir: Annotated[ + Path, + typer.Option("--cache-dir", help="Directory to cache git repositories."), + ] = Path("~/.cache/openneuro-dashboard/repos"), + git_concurrency: Annotated[ + int, typer.Option("--git-concurrency", help="Number of concurrent git ops.") + ] = 10, + s3_concurrency: Annotated[ + int, typer.Option("--s3-concurrency", help="Number of concurrent S3 requests.") + ] = 20, +) -> None: + """Check S3 files against git tree for each dataset.""" + import asyncio + + from .check_s3_files import check_all_datasets as _check_all_datasets + + asyncio.run( + _check_all_datasets( + output_dir, + cache_dir.expanduser(), + git_concurrency, + s3_concurrency, + verbose, + ) + ) + + +@app.command() +def summarize( + output_dir: OutputDir = Path("data"), + verbose: Verbose = False, +) -> None: + """Summarize collected data into dashboard JSON.""" + from .summarize import generate_summary + + generate_summary(output_dir) + + +@app.command() +def run_all( + output_dir: OutputDir = Path("data"), + verbose: Verbose = False, + cache_dir: Annotated[ + Path, + typer.Option("--cache-dir", help="Directory to cache git repositories."), + ] = Path("~/.cache/openneuro-dashboard/repos"), + max_datasets: Annotated[ + Optional[int], + typer.Option("--max-datasets", help="Maximum number of datasets to process."), + ] = None, +) -> None: + """Run the full data-population pipeline.""" + import asyncio + + from .check_github import check_all_datasets as _check_github + from .check_s3_files import check_all_datasets as _check_s3_files + from .check_s3_version import check_all_datasets as _check_s3_version + from .fetch_graphql import fetch_and_write as _fetch_graphql + from .summarize import generate_summary as _summarize + + resolved_cache = cache_dir.expanduser() + + async def _run() -> None: + await _fetch_graphql(output_dir, verbose=verbose, max_datasets=max_datasets) + await _check_github(output_dir, verbose=verbose) + await _check_s3_version(output_dir, verbose=verbose) + await _check_s3_files(output_dir, resolved_cache, verbose=verbose) + + asyncio.run(_run()) + _summarize(output_dir) + + +@app.command() +def gen_data( + output_dir: OutputDir = Path("data"), + verbose: Verbose = False, + num_datasets: Annotated[ + int, typer.Option("--num-datasets", help="Number of datasets to generate.") + ] = 50, + seed: Annotated[ + Optional[int], + typer.Option("--seed", help="Random seed for reproducibility."), + ] = None, +) -> None: + """Generate synthetic test data.""" + from .gen_data import git_tree, github, graphql, s3_diff, s3_version + from .summarize import generate_summary + + if seed is not None: + import random + + random.seed(seed) + + graphql.generate(output_dir, num_datasets) + github.generate(output_dir) + s3_version.generate(output_dir) + git_tree.generate(output_dir) + s3_diff.generate(output_dir) + generate_summary(output_dir) + + +if __name__ == "__main__": + app() diff --git a/scripts/check_github.py b/code/src/openneuro_dashboard/check_github.py similarity index 53% rename from scripts/check_github.py rename to code/src/openneuro_dashboard/check_github.py index b9a92d1530..f1bf1337bd 100644 --- a/scripts/check_github.py +++ b/code/src/openneuro_dashboard/check_github.py @@ -1,6 +1,4 @@ -#!/usr/bin/env python3 -""" -Check GitHub mirror status for all datasets. +"""Check GitHub mirror status for all datasets. Reads: - data/datasets-registry.json @@ -11,118 +9,66 @@ - data/datasets/{id}/github.json """ -import argparse +from __future__ import annotations + import asyncio -from asyncio.subprocess import PIPE from pathlib import Path -from dataclasses import dataclass, field - -from utils import SCHEMA_VERSION, write_json, load_json, format_timestamp - -@dataclass -class SubprocessResult: - args: tuple[str, ...] - returncode: int - stdout: bytes = field(repr=False) - stderr: bytes = field(repr=False) +from ondiagnostics.tasks.git import list_refs - -async def git(*args: str) -> SubprocessResult: - """Run a git command and return the exit code, stdout, and stderr.""" - args_tuple = ("git", *args) - - proc = await asyncio.create_subprocess_exec(*args_tuple, stdout=PIPE, stderr=PIPE) - stdout, stderr = await proc.communicate() - assert proc.returncode is not None - return SubprocessResult( - args=args_tuple, returncode=proc.returncode, stdout=stdout, stderr=stderr - ) +from .utils import SCHEMA_VERSION, format_timestamp, load_json, write_json async def check_github_mirror( dataset_id: str, output_dir: Path, verbose: bool = False ) -> dict | None: + """Check GitHub mirror status for a single dataset. + + Parameters + ---------- + dataset_id + Dataset ID to check. + output_dir + Base output directory. + verbose + Enable verbose logging. + + Returns + ------- + dict or None + GitHub status dict, or None if the check failed. """ - Check GitHub mirror status for a single dataset. - - Args: - dataset_id: Dataset ID to check - output_dir: Base output directory - verbose: Enable verbose logging - - Returns: - GitHub status dict or None if check failed - """ - repo = f"https://github.com/OpenNeuroDatasets/{dataset_id}.git" - - # Run git ls-remote to get all refs - result = await git("ls-remote", "--symref", repo) - - if result.returncode != 0: - if b"Repository not found" in result.stderr: - print(f"✗ {dataset_id}: Repository not found on GitHub") - else: - print(f"✗ {dataset_id}: git ls-remote failed: {result.stderr.decode()}") - return None + repo_url = f"https://github.com/OpenNeuroDatasets/{dataset_id}.git" - if not result.stdout.strip(): - print(f"✗ {dataset_id}: Empty response from git ls-remote") + refs = await list_refs(repo_url) + if refs is None: + print(f" {dataset_id}: failed to list refs") return None - # Parse output - lines = result.stdout.decode().strip().split("\n") - - head_ref = None - branches = {} - tags = {} - - for line in lines: - parts = line.split() - - # Handle symref (HEAD) - if parts[0] == "ref:": - # Format: "ref: refs/heads/master HEAD" - ref_target = parts[1] # e.g., "refs/heads/master" - head_ref = ref_target.split("/")[-1] # Extract "master" - continue - - # Handle normal refs - if len(parts) != 2: - continue - - sha, ref = parts - - if ref == "HEAD": - continue # Skip HEAD SHA line (we got symref above) - elif ref.startswith("refs/heads/"): - branch_name = ref.replace("refs/heads/", "") - branches[branch_name] = sha - elif ref.startswith("refs/tags/"): - tag_name = ref.replace("refs/tags/", "") - tags[tag_name] = sha - - if head_ref is None: - print(f"⚠ {dataset_id}: Could not determine HEAD ref") - # Default to common values if not found - if "master" in branches: - head_ref = "master" - elif "main" in branches: - head_ref = "main" + head = refs.head + if head is None: + # Fallback to common defaults + if "master" in refs.branches: + head = "master" + elif "main" in refs.branches: + head = "main" else: - head_ref = "unknown" + head = "unknown" github_data = { "schemaVersion": SCHEMA_VERSION, "lastChecked": format_timestamp(), - "head": head_ref, - "branches": branches, - "tags": tags, + "head": head, + "branches": refs.branches, + "tags": refs.tags, } if verbose: print( - f"✓ {dataset_id}: {len(branches)} branches, {len(tags)} tags, HEAD={head_ref}" + f" {dataset_id}: " + f"{len(refs.branches)} branches, " + f"{len(refs.tags)} tags, " + f"HEAD={head}" ) return github_data @@ -131,30 +77,30 @@ async def check_github_mirror( async def check_all_datasets( output_dir: Path, concurrency: int = 10, verbose: bool = False ) -> None: - """ - Check GitHub mirrors for all datasets with concurrency control. - - Args: - output_dir: Base output directory - concurrency: Maximum number of concurrent git operations - verbose: Enable verbose logging + """Check GitHub mirrors for all datasets. + + Parameters + ---------- + output_dir + Base output directory. + concurrency + Maximum number of concurrent git operations. + verbose + Enable verbose logging. """ print("Checking GitHub mirrors...") - # Load registry registry = load_json(output_dir / "datasets-registry.json") datasets = list(registry["latestSnapshots"].keys()) total = len(datasets) print(f"Found {total} datasets to check") - # Semaphore to limit concurrency semaphore = asyncio.Semaphore(concurrency) async def check_with_semaphore( dataset_id: str, index: int ) -> tuple[str, dict | None]: - """Check a dataset with semaphore for rate limiting.""" async with semaphore: result = await check_github_mirror(dataset_id, output_dir, verbose) @@ -163,14 +109,12 @@ async def check_with_semaphore( return dataset_id, result - # Launch all checks concurrently (but semaphore limits actual parallelism) tasks = [ check_with_semaphore(dataset_id, i) for i, dataset_id in enumerate(datasets) ] results = await asyncio.gather(*tasks) - # Write results success_count = 0 failed_count = 0 @@ -183,25 +127,31 @@ async def check_with_semaphore( write_json(dataset_dir / "github.json", github_data) success_count += 1 - print("\n✓ GitHub check complete") + print("\nGitHub check complete") print(f" Success: {success_count}/{total}") print(f" Failed: {failed_count}/{total}") async def validate_github_data(output_dir: Path, verbose: bool = False) -> None: - """ - Validate GitHub data against expected snapshots. + """Validate GitHub data against expected snapshots. - This checks that: + Checks that: - All expected tags exist on GitHub - HEAD points to the latest snapshot - Git SHAs match expected values + + Parameters + ---------- + output_dir + Base output directory. + verbose + Enable verbose logging. """ print("\nValidating GitHub data...") registry = load_json(output_dir / "datasets-registry.json") - issues = { + issues: dict[str, list[str]] = { "missing_tags": [], "sha_mismatch": [], "head_mismatch": [], @@ -210,14 +160,12 @@ async def validate_github_data(output_dir: Path, verbose: bool = False) -> None: for dataset_id, latest_snapshot in registry["latestSnapshots"].items(): dataset_dir = output_dir / "datasets" / dataset_id - # Load GitHub data github_path = dataset_dir / "github.json" if not github_path.exists(): continue github_data = load_json(github_path) - # Load snapshots snapshots_data = load_json(dataset_dir / "snapshots.json") tags = snapshots_data["tags"] @@ -280,39 +228,3 @@ async def validate_github_data(output_dir: Path, verbose: bool = False) -> None: if not any(len(v) > 0 for v in issues.values()): print(" No issues found!") - - -def main(): - parser = argparse.ArgumentParser( - description="Check GitHub mirror status for OpenNeuro datasets" - ) - parser.add_argument( - "--output-dir", - type=Path, - default=Path("data"), - help="Output directory (default: data)", - ) - parser.add_argument( - "--concurrency", - type=int, - default=10, - help="Maximum concurrent git operations (default: 10)", - ) - parser.add_argument( - "--validate", action="store_true", help="Validate results after checking" - ) - parser.add_argument("--verbose", action="store_true", help="Enable verbose logging") - - args = parser.parse_args() - - async def run(): - await check_all_datasets(args.output_dir, args.concurrency, args.verbose) - - if args.validate: - await validate_github_data(args.output_dir, args.verbose) - - asyncio.run(run()) - - -if __name__ == "__main__": - main() diff --git a/scripts/check_s3_files.py b/code/src/openneuro_dashboard/check_s3_files.py similarity index 66% rename from scripts/check_s3_files.py rename to code/src/openneuro_dashboard/check_s3_files.py index 5844091128..b9ee61afe0 100644 --- a/scripts/check_s3_files.py +++ b/code/src/openneuro_dashboard/check_s3_files.py @@ -1,13 +1,4 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.13" -# dependencies = [ -# "aioboto3>=13.4.0", -# "pygit2>=1.17.0", -# ] -# /// -""" -Stage 4: Compare S3 file listing against git tree. +"""Compare S3 file listing against git tree. Manages a bare repo cache for git trees. Clones on cache miss, fetches missing tags from existing repos. @@ -21,20 +12,25 @@ - data/datasets/{id}/s3-diff.json """ -import argparse +from __future__ import annotations + import asyncio -from asyncio.subprocess import PIPE -from dataclasses import dataclass, field -from datetime import datetime, UTC, timedelta +from datetime import UTC, datetime, timedelta from pathlib import Path import aioboto3 import pygit2 from botocore import UNSIGNED from botocore.config import Config as BotoConfig +from ondiagnostics.subprocs import git -from utils import SCHEMA_VERSION, write_json, load_json, format_timestamp - +from .utils import ( + SCHEMA_VERSION, + format_timestamp, + load_json, + load_json_safe, + write_json, +) S3_BUCKET = "openneuro.org" S3_REGION = "us-east-1" @@ -42,34 +38,6 @@ STALE_DAYS = 7 -@dataclass -class SubprocessResult: - args: tuple[str, ...] - returncode: int - stdout: bytes = field(repr=False) - stderr: bytes = field(repr=False) - - -async def git(*args: str, cwd: Path | None = None) -> SubprocessResult: - """Run a git command and return the result.""" - args_tuple = ("git", *args) - proc = await asyncio.create_subprocess_exec( - *args_tuple, stdout=PIPE, stderr=PIPE, cwd=cwd - ) - stdout, stderr = await proc.communicate() - assert proc.returncode is not None - return SubprocessResult( - args=args_tuple, returncode=proc.returncode, stdout=stdout, stderr=stderr - ) - - -def load_json_safe(path: Path) -> dict | None: - """Load JSON file if it exists, return None otherwise.""" - if path.exists(): - return load_json(path) - return None - - def is_eligible( dataset_id: str, s3_version: dict | None, @@ -103,7 +71,7 @@ def is_eligible( return False # Older than STALE_DAYS: re-run regardless except (ValueError, TypeError): - pass # Can't parse timestamp — re-run + pass # Can't parse timestamp -- re-run return True @@ -118,17 +86,24 @@ async def ensure_tag_cached( if not repo_path.exists(): # Clone bare repo result = await git( - "clone", "--bare", "--filter=blob:none", "--depth=1", - "--branch", tag, + "clone", + "--bare", + "--filter=blob:none", + "--depth=1", + "--branch", + tag, f"{GITHUB_BASE}/{dataset_id}.git", str(repo_path), ) if result.returncode != 0: - print(f"✗ {dataset_id}: git clone failed: {result.stderr.decode().strip()}") + print( + f" {dataset_id}: git clone failed: " + f"{result.stderr.decode().strip()}" + ) return False return True - # Repo exists — check if tag is present + # Repo exists -- check if tag is present try: repo = await asyncio.to_thread(pygit2.Repository, str(repo_path)) ref_name = f"refs/tags/{tag}" @@ -136,16 +111,25 @@ async def ensure_tag_cached( if has_tag: return True except Exception as e: - print(f"⚠ {dataset_id}: Error checking repo: {e}") + print(f" {dataset_id}: Error checking repo: {e}") - # Fetch the missing tag + # Fetch the missing tag (use -C to set working directory) result = await git( - "fetch", "--refetch", "--filter=blob:none", "--depth=1", - "origin", "tag", tag, - cwd=repo_path, + "-C", + str(repo_path), + "fetch", + "--refetch", + "--filter=blob:none", + "--depth=1", + "origin", + "tag", + tag, ) if result.returncode != 0: - print(f"✗ {dataset_id}: git fetch failed: {result.stderr.decode().strip()}") + print( + f" {dataset_id}: git fetch failed: " + f"{result.stderr.decode().strip()}" + ) return False return True @@ -161,9 +145,9 @@ def walk_git_tree(repo_path: Path, tag: str) -> set[str]: commit = ref.peel(pygit2.Commit) tree = commit.tree - files = set() + files: set[str] = set() - def _walk(tree_obj, prefix=""): + def _walk(tree_obj: pygit2.Tree, prefix: str = "") -> None: for entry in tree_obj: path = entry.name if not prefix else f"{prefix}/{entry.name}" if entry.type == pygit2.GIT_OBJECT_TREE: @@ -178,7 +162,13 @@ def _walk(tree_obj, prefix=""): async def list_s3_files( dataset_id: str, semaphore: asyncio.Semaphore ) -> set[str] | None: - """List all S3 objects under the dataset prefix. Returns file paths as a set.""" + """List all S3 objects under the dataset prefix. + + Returns file paths relative to the dataset prefix as a set, + or None on failure. + + Uses unsigned access for the public OpenNeuro bucket. + """ prefix = f"{dataset_id}/" async with semaphore: @@ -188,29 +178,35 @@ async def list_s3_files( region_name=S3_REGION, config=BotoConfig(signature_version=UNSIGNED), ) as s3: - files = set() + files: set[str] = set() paginator = s3.get_paginator("list_objects_v2") try: - async for page in paginator.paginate(Bucket=S3_BUCKET, Prefix=prefix): + async for page in paginator.paginate( + Bucket=S3_BUCKET, Prefix=prefix + ): for obj in page.get("Contents", []): key = obj["Key"] # Strip the dataset prefix - rel_path = key[len(prefix):] + rel_path = key[len(prefix) :] if rel_path: files.add(rel_path) except Exception as e: - print(f"✗ {dataset_id}: S3 listing failed: {e}") + print(f" {dataset_id}: S3 listing failed: {e}") return None return files -def compute_context(sorted_files: list[str], changed: set[str], radius: int = 3) -> list[str]: - """Compute context files within `radius` sorted positions of any changed file.""" - context = set() +def compute_context( + sorted_files: list[str], changed: set[str], radius: int = 3 +) -> list[str]: + """Compute context files within ``radius`` sorted positions of any changed file.""" + context: set[str] = set() for i, f in enumerate(sorted_files): if f in changed: - for j in range(max(0, i - radius), min(len(sorted_files), i + radius + 1)): + for j in range( + max(0, i - radius), min(len(sorted_files), i + radius + 1) + ): neighbor = sorted_files[j] if neighbor not in changed: context.add(neighbor) @@ -218,8 +214,11 @@ def compute_context(sorted_files: list[str], changed: set[str], radius: int = 3) def compute_diff( - dataset_id: str, tag: str, s3_version: str, - git_files: set[str], s3_files: set[str] + dataset_id: str, + tag: str, + s3_version: str, + git_files: set[str], + s3_files: set[str], ) -> dict: """Compute the S3 file diff and return the s3-diff.json data.""" added = sorted(git_files - s3_files) @@ -269,7 +268,7 @@ async def process_dataset( try: git_files = await asyncio.to_thread(walk_git_tree, repo_path, tag) except Exception as e: - print(f"✗ {dataset_id}: Failed to walk git tree: {e}") + print(f" {dataset_id}: Failed to walk git tree: {e}") return False # Step 3: List S3 files @@ -288,9 +287,11 @@ async def process_dataset( added_count = len(diff["added"]) removed_count = len(diff["removed"]) if added_count or removed_count: - print(f"✗ {dataset_id}: +{added_count} -{removed_count} ({diff['status']})") + print( + f" {dataset_id}: +{added_count} -{removed_count} ({diff['status']})" + ) else: - print(f"✓ {dataset_id}: ok") + print(f" {dataset_id}: ok") return True @@ -336,11 +337,18 @@ async def check_all_datasets( failed = 0 # Process datasets concurrently - async def process_with_progress(dataset_id, s3_version_data, index): + async def process_with_progress( + dataset_id: str, s3_version_data: dict, index: int + ) -> None: nonlocal success, failed ok = await process_dataset( - dataset_id, s3_version_data, output_dir, cache_dir, - git_semaphore, s3_semaphore, verbose, + dataset_id, + s3_version_data, + output_dir, + cache_dir, + git_semaphore, + s3_semaphore, + verbose, ) if ok: success += 1 @@ -356,62 +364,18 @@ async def process_with_progress(dataset_id, s3_version_data, index): ] await asyncio.gather(*tasks) - print(f"\n✓ S3 file check complete") + print("\nS3 file check complete") print(f" Success: {success}/{len(eligible)}") print(f" Failed: {failed}/{len(eligible)}") print(f" Skipped: {total - len(eligible)}/{total}") -def main(): - parser = argparse.ArgumentParser( - description="Check S3 files against git tree for OpenNeuro datasets" - ) - parser.add_argument( - "--output-dir", type=Path, default=Path("data"), - help="Output directory (default: data)", - ) - parser.add_argument( - "--cache-dir", type=Path, - default=Path.home() / ".cache" / "openneuro-dashboard" / "repos", - help="Bare repo cache directory", - ) - parser.add_argument( - "--git-concurrency", type=int, default=10, - help="Max concurrent git operations (default: 10)", - ) - parser.add_argument( - "--s3-concurrency", type=int, default=20, - help="Max concurrent S3 requests (default: 20)", - ) - parser.add_argument( - "--validate", action="store_true", - help="Validate results after checking", - ) - parser.add_argument( - "--verbose", action="store_true", - help="Enable verbose logging", - ) - - args = parser.parse_args() - - asyncio.run( - check_all_datasets( - args.output_dir, args.cache_dir, - args.git_concurrency, args.s3_concurrency, - args.verbose, - ) - ) - - if args.validate: - validate_results(args.output_dir) - - def validate_results(output_dir: Path) -> None: """Validate s3-diff.json files for consistency.""" print("\nValidating S3 file check results...") registry = load_json(output_dir / "datasets-registry.json") - issues = [] + issues: list[str] = [] for dataset_id in registry["latestSnapshots"]: dataset_dir = output_dir / "datasets" / dataset_id @@ -422,16 +386,26 @@ def validate_results(output_dir: Path) -> None: diff = load_json(diff_path) # Check required fields - for field in ("added", "removed", "context", "totalS3Files", "totalGitFiles", "status"): - if field not in diff: - issues.append(f"{dataset_id}: missing field '{field}'") + for fld in ( + "added", + "removed", + "context", + "totalS3Files", + "totalGitFiles", + "status", + ): + if fld not in diff: + issues.append(f"{dataset_id}: missing field '{fld}'") # Check status consistency added = diff.get("added", []) removed = diff.get("removed", []) status = diff.get("status") if status == "ok" and (len(added) > 0 or len(removed) > 0): - issues.append(f"{dataset_id}: status is 'ok' but has {len(added)} added, {len(removed)} removed") + issues.append( + f"{dataset_id}: status is 'ok' but has " + f"{len(added)} added, {len(removed)} removed" + ) if status == "error" and len(added) == 0 and len(removed) == 0: issues.append(f"{dataset_id}: status is 'error' but diff is empty") @@ -442,8 +416,4 @@ def validate_results(output_dir: Path) -> None: if len(issues) > 20: print(f" ... and {len(issues) - 20} more") else: - print(" ✓ No issues found") - - -if __name__ == "__main__": - main() + print(" No issues found") diff --git a/scripts/check_s3_version.py b/code/src/openneuro_dashboard/check_s3_version.py similarity index 64% rename from scripts/check_s3_version.py rename to code/src/openneuro_dashboard/check_s3_version.py index 602cd339fc..1b6dbef42e 100644 --- a/scripts/check_s3_version.py +++ b/code/src/openneuro_dashboard/check_s3_version.py @@ -1,13 +1,4 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.13" -# dependencies = [ -# "httpx>=0.28.1", -# ] -# /// -#!/usr/bin/env python3 -""" -Check S3 version from dataset_description.json for all datasets. +"""Check S3 version from dataset_description.json for all datasets. Reads: - data/datasets-registry.json @@ -16,7 +7,8 @@ - data/datasets/{id}/s3-version.json """ -import argparse +from __future__ import annotations + import asyncio import json import re @@ -24,8 +16,7 @@ import httpx -from utils import SCHEMA_VERSION, write_json, load_json, format_timestamp - +from .utils import SCHEMA_VERSION, format_timestamp, load_json, write_json S3_BASE_URL = "https://s3.amazonaws.com/openneuro.org" DOI_PATTERN = re.compile(r"10\.18112/openneuro\.([^.]+)\.v(.+)") @@ -35,66 +26,81 @@ async def fetch_dataset_description( client: httpx.AsyncClient, dataset_id: str, latest_snapshot: str, - verbose: bool = False + verbose: bool = False, ) -> dict: - """ - Fetch dataset_description.json from S3 for a dataset. - - Args: - client: HTTP client - dataset_id: Dataset ID - latest_snapshot: Latest snapshot tag from GraphQL - verbose: Enable verbose logging - - Returns: - S3 version data dict (always returns a dict) + """Fetch dataset_description.json from S3 for a dataset. + + Parameters + ---------- + client + HTTP client. + dataset_id + Dataset ID. + latest_snapshot + Latest snapshot tag from GraphQL. + verbose + Enable verbose logging. + + Returns + ------- + dict + S3 version data dict (always returns a dict). """ url = f"{S3_BASE_URL}/{dataset_id}/dataset_description.json" - + try: response = await client.get(url, timeout=30.0) response.raise_for_status() - + dataset_desc = response.json() - + # Extract DOI doi = dataset_desc.get("DatasetDOI", "") - + if not doi: # Case 2: No DOI field - assume latest snapshot if verbose: - print(f"⚠ {dataset_id}: No DatasetDOI field, assuming latest ({latest_snapshot})") + print( + f" {dataset_id}: No DatasetDOI field, " + f"assuming latest ({latest_snapshot})" + ) return { "schemaVersion": SCHEMA_VERSION, "lastChecked": format_timestamp(), "accessible": True, "datasetDescriptionDOI": None, "extractedVersion": latest_snapshot, - "versionSource": "assumed_latest" + "versionSource": "assumed_latest", } - + # Try to extract version from DOI match = DOI_PATTERN.search(doi) if not match: # Case 2: Custom DOI - assume latest snapshot if verbose: - print(f"⚠ {dataset_id}: Custom DOI ({doi}), assuming latest ({latest_snapshot})") + print( + f" {dataset_id}: Custom DOI ({doi}), " + f"assuming latest ({latest_snapshot})" + ) return { "schemaVersion": SCHEMA_VERSION, "lastChecked": format_timestamp(), "accessible": True, "datasetDescriptionDOI": doi, "extractedVersion": latest_snapshot, - "versionSource": "assumed_latest" + "versionSource": "assumed_latest", } - + doi_dataset_id = match.group(1) version = match.group(2) - + # Check for DOI dataset ID mismatch if doi_dataset_id != dataset_id: if verbose: - print(f"⚠ {dataset_id}: DOI has wrong ID ({doi_dataset_id}), using version {version}") + print( + f" {dataset_id}: DOI has wrong ID ({doi_dataset_id}), " + f"using version {version}" + ) return { "schemaVersion": SCHEMA_VERSION, "lastChecked": format_timestamp(), @@ -103,37 +109,40 @@ async def fetch_dataset_description( "extractedVersion": version, "versionSource": "doi", "doiIdMismatch": True, - "doiDatasetId": doi_dataset_id + "doiDatasetId": doi_dataset_id, } - + # Case 1: Success - version from DOI if verbose: - print(f"✓ {dataset_id}: version {version}") - + print(f" {dataset_id}: version {version}") + return { "schemaVersion": SCHEMA_VERSION, "lastChecked": format_timestamp(), "accessible": True, "datasetDescriptionDOI": doi, "extractedVersion": version, - "versionSource": "doi" + "versionSource": "doi", } - + except httpx.HTTPStatusError as e: if e.response.status_code == 403: # Case 3: BLOCKED - Access denied - print(f"🔒 {dataset_id}: Access denied (403)") + print(f" {dataset_id}: Access denied (403)") return { "schemaVersion": SCHEMA_VERSION, "lastChecked": format_timestamp(), "accessible": False, "httpStatus": 403, "datasetDescriptionDOI": None, - "extractedVersion": None + "extractedVersion": None, } elif e.response.status_code == 404: # Case 4: Missing dataset_description.json - assume latest - print(f"⚠ {dataset_id}: dataset_description.json not found (404), assuming latest ({latest_snapshot})") + print( + f" {dataset_id}: dataset_description.json not found (404), " + f"assuming latest ({latest_snapshot})" + ) return { "schemaVersion": SCHEMA_VERSION, "lastChecked": format_timestamp(), @@ -141,11 +150,14 @@ async def fetch_dataset_description( "datasetDescriptionDOI": None, "extractedVersion": latest_snapshot, "versionSource": "assumed_latest", - "datasetDescriptionMissing": True + "datasetDescriptionMissing": True, } else: # Other HTTP errors - assume latest - print(f"⚠ {dataset_id}: HTTP {e.response.status_code}, assuming latest ({latest_snapshot})") + print( + f" {dataset_id}: HTTP {e.response.status_code}, " + f"assuming latest ({latest_snapshot})" + ) return { "schemaVersion": SCHEMA_VERSION, "lastChecked": format_timestamp(), @@ -153,12 +165,14 @@ async def fetch_dataset_description( "datasetDescriptionDOI": None, "extractedVersion": latest_snapshot, "versionSource": "assumed_latest", - "httpError": e.response.status_code + "httpError": e.response.status_code, } - + except httpx.RequestError as e: # Network error - assume latest - print(f"⚠ {dataset_id}: Request error, assuming latest ({latest_snapshot})") + print( + f" {dataset_id}: Request error, assuming latest ({latest_snapshot})" + ) return { "schemaVersion": SCHEMA_VERSION, "lastChecked": format_timestamp(), @@ -166,12 +180,14 @@ async def fetch_dataset_description( "datasetDescriptionDOI": None, "extractedVersion": latest_snapshot, "versionSource": "assumed_latest", - "requestError": str(e) + "requestError": str(e), } - + except json.JSONDecodeError: # Invalid JSON - assume latest - print(f"⚠ {dataset_id}: Invalid JSON, assuming latest ({latest_snapshot})") + print( + f" {dataset_id}: Invalid JSON, assuming latest ({latest_snapshot})" + ) return { "schemaVersion": SCHEMA_VERSION, "lastChecked": format_timestamp(), @@ -179,12 +195,15 @@ async def fetch_dataset_description( "datasetDescriptionDOI": None, "extractedVersion": latest_snapshot, "versionSource": "assumed_latest", - "invalidJson": True + "invalidJson": True, } - + except Exception as e: # Unexpected error - assume latest - print(f"⚠ {dataset_id}: Unexpected error ({e}), assuming latest ({latest_snapshot})") + print( + f" {dataset_id}: Unexpected error ({e}), " + f"assuming latest ({latest_snapshot})" + ) return { "schemaVersion": SCHEMA_VERSION, "lastChecked": format_timestamp(), @@ -192,70 +211,79 @@ async def fetch_dataset_description( "datasetDescriptionDOI": None, "extractedVersion": latest_snapshot, "versionSource": "assumed_latest", - "unexpectedError": str(e) + "unexpectedError": str(e), } async def check_all_datasets( output_dir: Path, concurrency: int = 20, - verbose: bool = False + verbose: bool = False, ) -> None: - """ - Check S3 versions for all datasets with concurrency control. - - Args: - output_dir: Base output directory - concurrency: Maximum number of concurrent HTTP requests - verbose: Enable verbose logging + """Check S3 versions for all datasets with concurrency control. + + Parameters + ---------- + output_dir + Base output directory. + concurrency + Maximum number of concurrent HTTP requests. + verbose + Enable verbose logging. """ print("Checking S3 versions...") - + # Load registry registry = load_json(output_dir / "datasets-registry.json") datasets = registry["latestSnapshots"] total = len(datasets) - + print(f"Found {total} datasets to check") - + # Create HTTP client with connection pooling - limits = httpx.Limits(max_keepalive_connections=concurrency, max_connections=concurrency) - + limits = httpx.Limits( + max_keepalive_connections=concurrency, max_connections=concurrency + ) + async with httpx.AsyncClient(limits=limits) as client: # Semaphore to limit concurrency semaphore = asyncio.Semaphore(concurrency) - - async def check_with_semaphore(dataset_id: str, latest_snapshot: str, index: int) -> tuple[str, dict]: + + async def check_with_semaphore( + dataset_id: str, latest_snapshot: str, index: int + ) -> tuple[str, dict]: """Check a dataset with semaphore for rate limiting.""" async with semaphore: - result = await fetch_dataset_description(client, dataset_id, latest_snapshot, verbose) - + result = await fetch_dataset_description( + client, dataset_id, latest_snapshot, verbose + ) + if not verbose and (index + 1) % 100 == 0: print(f" Progress: {index + 1}/{total}") - + return dataset_id, result - + # Launch all checks concurrently (but semaphore limits actual parallelism) tasks = [ check_with_semaphore(dataset_id, latest_snapshot, i) for i, (dataset_id, latest_snapshot) in enumerate(datasets.items()) ] - + results = await asyncio.gather(*tasks) - + # Write results and collect statistics stats = { - "case1_doi": 0, # DOI with version - "case2_assumed": 0, # Missing/custom DOI - "case3_blocked": 0, # 403 access denied - "case4_not_found": 0, # 404 not found - "doi_mismatch": 0, # Wrong dataset ID in DOI + "case1_doi": 0, # DOI with version + "case2_assumed": 0, # Missing/custom DOI + "case3_blocked": 0, # 403 access denied + "case4_not_found": 0, # 404 not found + "doi_mismatch": 0, # Wrong dataset ID in DOI } - + for dataset_id, s3_version_data in results: dataset_dir = output_dir / "datasets" / dataset_id write_json(dataset_dir / "s3-version.json", s3_version_data) - + # Categorize if not s3_version_data.get("accessible", True): stats["case3_blocked"] += 1 @@ -267,9 +295,9 @@ async def check_with_semaphore(dataset_id: str, latest_snapshot: str, index: int stats["case4_not_found"] += 1 else: stats["case2_assumed"] += 1 - - print(f"\n✓ S3 version check complete ({total} datasets)") - print(f"\nBreakdown:") + + print(f"\nS3 version check complete ({total} datasets)") + print("\nBreakdown:") print(f" Case 1 - DOI with version: {stats['case1_doi']}") print(f" Case 2 - Assumed latest: {stats['case2_assumed']}") print(f" Case 3 - Blocked (403): {stats['case3_blocked']}") @@ -280,126 +308,98 @@ async def check_with_semaphore(dataset_id: str, latest_snapshot: str, index: int async def validate_s3_versions( output_dir: Path, - verbose: bool = False + verbose: bool = False, ) -> None: - """ - Validate S3 versions against expected latest snapshots. + """Validate S3 versions against expected latest snapshots. + + Parameters + ---------- + output_dir + Base output directory. + verbose + Enable verbose logging. """ print("\nValidating S3 versions...") - + registry = load_json(output_dir / "datasets-registry.json") - - issues = { + + issues: dict[str, list[str]] = { "version_mismatch": [], "unknown_version": [], "blocked": [], } - + for dataset_id, latest_snapshot in registry["latestSnapshots"].items(): dataset_dir = output_dir / "datasets" / dataset_id - + # Load S3 version s3_version_path = dataset_dir / "s3-version.json" if not s3_version_path.exists(): continue - + s3_version_data = load_json(s3_version_path) - + # Track blocked datasets if not s3_version_data.get("accessible", True): issues["blocked"].append(dataset_id) continue - + extracted_version = s3_version_data.get("extractedVersion") - + if not extracted_version: continue - + # Load snapshots to check if version is valid snapshots_data = load_json(dataset_dir / "snapshots.json") valid_tags = snapshots_data["tags"] - + # Check if version matches latest (only flag if from DOI) - if s3_version_data.get("versionSource") == "doi" and extracted_version != latest_snapshot: + if ( + s3_version_data.get("versionSource") == "doi" + and extracted_version != latest_snapshot + ): issues["version_mismatch"].append( - f"{dataset_id}: S3 has {extracted_version}, latest is {latest_snapshot}" + f"{dataset_id}: S3 has {extracted_version}, " + f"latest is {latest_snapshot}" ) - + # Check if version is a known snapshot if extracted_version not in valid_tags: issues["unknown_version"].append( - f"{dataset_id}: S3 version {extracted_version} not in known snapshots" + f"{dataset_id}: S3 version {extracted_version} " + f"not in known snapshots" ) - + # Print validation results print("\nValidation results:") - + if issues["blocked"]: print(f"\n Blocked datasets (403) [{len(issues['blocked'])}]:") for dataset_id in issues["blocked"][:10]: print(f" {dataset_id}") if len(issues["blocked"]) > 10: print(f" ... and {len(issues['blocked']) - 10} more") - + if issues["version_mismatch"]: - print(f"\n Version mismatches (DOI ≠ latest) [{len(issues['version_mismatch'])}]:") + print( + f"\n Version mismatches (DOI != latest) " + f"[{len(issues['version_mismatch'])}]:" + ) for issue in issues["version_mismatch"][:10]: print(f" {issue}") if len(issues["version_mismatch"]) > 10: - print(f" ... and {len(issues['version_mismatch']) - 10} more") - + print( + f" ... and {len(issues['version_mismatch']) - 10} more" + ) + if issues["unknown_version"]: print(f"\n Unknown versions [{len(issues['unknown_version'])}]:") for issue in issues["unknown_version"][:10]: print(f" {issue}") if len(issues["unknown_version"]) > 10: - print(f" ... and {len(issues['unknown_version']) - 10} more") - + print( + f" ... and {len(issues['unknown_version']) - 10} more" + ) + if not any(len(v) > 0 for v in issues.values()): print(" No issues found!") - - -def main(): - parser = argparse.ArgumentParser( - description="Check S3 versions for OpenNeuro datasets" - ) - parser.add_argument( - "--output-dir", - type=Path, - default=Path("data"), - help="Output directory (default: data)" - ) - parser.add_argument( - "--concurrency", - type=int, - default=20, - help="Maximum concurrent HTTP requests (default: 20)" - ) - parser.add_argument( - "--validate", - action="store_true", - help="Validate results after checking" - ) - parser.add_argument( - "--verbose", - action="store_true", - help="Enable verbose logging" - ) - - args = parser.parse_args() - - async def run(): - await check_all_datasets( - args.output_dir, - args.concurrency, - args.verbose - ) - - if args.validate: - await validate_s3_versions(args.output_dir, args.verbose) - - asyncio.run(run()) - - -if __name__ == "__main__": - main() diff --git a/code/src/openneuro_dashboard/fetch_graphql.py b/code/src/openneuro_dashboard/fetch_graphql.py new file mode 100644 index 0000000000..61e477fffe --- /dev/null +++ b/code/src/openneuro_dashboard/fetch_graphql.py @@ -0,0 +1,262 @@ +"""Fetch dataset information from OpenNeuro GraphQL API. + +Writes: +- data/datasets-registry.json +- data/datasets/{id}/snapshots.json +- data/datasets/{id}/snapshots/{tag}/metadata.json +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from ondiagnostics.graphql import ( + GraphQLResponse, + PageInfo, + create_client, + get_page, +) + +from .utils import SCHEMA_VERSION, format_timestamp, load_json, write_json + + +async def _fetch_pages( + client, + queue: asyncio.Queue, + page_size: int, + verbose: bool, + max_datasets: int | None = None, +) -> None: + """Fetch pages and put them in queue for processing. + + Parameters + ---------- + client + GQL client session. + queue + Queue to put pages into. + page_size + Number of datasets per page. + verbose + Enable verbose logging. + max_datasets + Stop after fetching this many datasets (approximate). + """ + page_info = PageInfo() + page_num = 0 + total_fetched = 0 + + try: + while page_info.hasNextPage: + result: GraphQLResponse = await get_page( + client, page_size, page_info.endCursor, include_snapshots=True + ) + + page_info = result.datasets.pageInfo + page_num += 1 + total_fetched += len(result.datasets.edges) + + if verbose: + print( + f" Fetched page {page_num}: " + f"{len(result.datasets.edges)} datasets, " + f"hasNext={page_info.hasNextPage}" + ) + + await queue.put(result.datasets.edges) + + if max_datasets is not None and total_fetched >= max_datasets: + break + finally: + # Signal completion + await queue.put(None) + if verbose: + print(f" Page fetching complete: {page_num} pages") + + +def _write_dataset_json( + dataset_dir: Path, + snapshots_data: list, + latest_snapshot_tag: str, + dry_run: bool, +) -> None: + """Write snapshots.json and per-snapshot metadata.json for one dataset.""" + snapshots_index = { + "schemaVersion": SCHEMA_VERSION, + "tags": [snap.tag for snap in snapshots_data], + } + if dry_run: + print(f" [dry-run] Would write {dataset_dir / 'snapshots.json'}") + else: + write_json(dataset_dir / "snapshots.json", snapshots_index) + + for snapshot in snapshots_data: + snapshot_dir = dataset_dir / "snapshots" / snapshot.tag + metadata = { + "schemaVersion": SCHEMA_VERSION, + "hexsha": snapshot.hexsha, + "created": snapshot.created, + } + if dry_run: + print(f" [dry-run] Would write {snapshot_dir / 'metadata.json'}") + else: + write_json(snapshot_dir / "metadata.json", metadata) + + +async def fetch_and_write( + output_dir: Path, + page_size: int = 100, + prefetch: int = 2, + dry_run: bool = False, + verbose: bool = False, + max_datasets: int | None = None, +) -> None: + """Fetch from GraphQL and write files. + + Parameters + ---------- + output_dir + Directory to write data files. + page_size + Number of datasets per GraphQL page. + prefetch + Number of pages to prefetch. + dry_run + If True, don't write files. + verbose + If True, enable verbose logging. + max_datasets + Maximum number of datasets to fetch. + """ + client = create_client() + + # Get total count + first_page = await get_page(client, 0, None) + total_count = first_page.datasets.pageInfo.count + print(f"Starting fetch: {total_count} total datasets") + + # Setup queue and background fetcher + queue: asyncio.Queue = asyncio.Queue(maxsize=prefetch) + fetch_task = asyncio.create_task( + _fetch_pages(client, queue, page_size, verbose, max_datasets) + ) + + # Track progress + latest_snapshots: dict[str, str] = {} + processed = 0 + timestamp = format_timestamp() + + try: + # Process datasets as they arrive + while True: + edges = await queue.get() + + # None signals end of pages + if edges is None: + break + + for edge in edges: + if edge is None: + continue + if edge.node is None: + print("Warning: Null node in edge") + continue + + dataset = edge.node + + # Validate snapshots exist + if not dataset.snapshots: + print( + f"Warning: Dataset {dataset.id} has no snapshots, " + f"skipping" + ) + continue + + processed += 1 + + if max_datasets is not None and processed > max_datasets: + break + + # Track latest snapshot for registry + latest_snapshots[dataset.id] = dataset.latestSnapshot.tag + + # Write per-dataset files + dataset_dir = output_dir / "datasets" / dataset.id + _write_dataset_json( + dataset_dir, + dataset.snapshots, + dataset.latestSnapshot.tag, + dry_run, + ) + + # Progress logging every 100 datasets + if processed % 100 == 0: + percent = 100 * processed / total_count + print( + f"Progress: {processed}/{total_count} ({percent:.1f}%)" + ) + + if max_datasets is not None and processed >= max_datasets: + break + finally: + # Ensure fetch task completes + await fetch_task + + # Write registry + registry = { + "schemaVersion": SCHEMA_VERSION, + "lastChecked": timestamp, + "totalCount": len(latest_snapshots), + "latestSnapshots": latest_snapshots, + } + + registry_path = output_dir / "datasets-registry.json" + if dry_run: + print(f" [dry-run] Would write {registry_path}") + else: + write_json(registry_path, registry) + + print(f"\nFetch complete: {processed} datasets processed") + if not dry_run: + print(f" Registry written to: {registry_path}") + + +def validate_output(output_dir: Path) -> None: + """Validate the output data for consistency.""" + print("\nValidating output...") + + registry_path = output_dir / "datasets-registry.json" + if not registry_path.exists(): + print(" FAIL: datasets-registry.json not found") + return + + registry = load_json(registry_path) + + issues = [] + for dataset_id, latest_snapshot in registry["latestSnapshots"].items(): + dataset_dir = output_dir / "datasets" / dataset_id + + snapshots_path = dataset_dir / "snapshots.json" + if not snapshots_path.exists(): + issues.append(f"{dataset_id}: snapshots.json missing") + continue + + snapshots = load_json(snapshots_path) + + if latest_snapshot not in snapshots["tags"]: + issues.append(f"{dataset_id}: latest {latest_snapshot} not in tags") + + for tag in snapshots["tags"]: + metadata_path = dataset_dir / "snapshots" / tag / "metadata.json" + if not metadata_path.exists(): + issues.append(f"{dataset_id}: metadata.json missing for {tag}") + + if issues: + print(f"\n Issues found ({len(issues)}):") + for issue in issues[:20]: + print(f" {issue}") + if len(issues) > 20: + print(f" ... and {len(issues) - 20} more") + else: + print(" OK: No issues found") diff --git a/code/src/openneuro_dashboard/gen_data/__init__.py b/code/src/openneuro_dashboard/gen_data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/gen_data/git_tree.py b/code/src/openneuro_dashboard/gen_data/git_tree.py similarity index 54% rename from scripts/gen_data/git_tree.py rename to code/src/openneuro_dashboard/gen_data/git_tree.py index 5432941d56..0afa95b23d 100644 --- a/scripts/gen_data/git_tree.py +++ b/code/src/openneuro_dashboard/gen_data/git_tree.py @@ -1,6 +1,4 @@ -#!/usr/bin/env python3 -""" -Stage 4: Simulate git tree fetching. +"""Stage 4: Generate simulated git tree file listings. Reads: - data/datasets-registry.json @@ -10,14 +8,14 @@ - data/datasets/{id}/snapshots/{tag}/files.json """ -import argparse import random from pathlib import Path -from utils import SCHEMA_VERSION, generate_file_paths, write_json, load_json +from ..utils import SCHEMA_VERSION, load_json, write_json +from .utils import generate_file_paths -def generate_git_files(output_dir: Path, dataset_size: str, seed: int = None): +def generate(output_dir: Path, dataset_size: str = "medium", seed: int = None): """Generate git file listings for all datasets.""" if seed is not None: random.seed(seed) @@ -33,7 +31,7 @@ def generate_git_files(output_dir: Path, dataset_size: str, seed: int = None): # Only generate for latest snapshot to save space latest_dir = dataset_dir / "snapshots" / latest_snapshot - metadata = load_json(latest_dir / "metadata.json") + load_json(latest_dir / "metadata.json") # Generate file list files = generate_file_paths(dataset_size) @@ -48,22 +46,4 @@ def generate_git_files(output_dir: Path, dataset_size: str, seed: int = None): if i % 100 == 0: print(f" Processed {i}/{len(datasets)}") - print(f"✓ Git file listing generation complete ({len(datasets)} datasets)") - - -def main(): - parser = argparse.ArgumentParser(description="Generate simulated git file listings") - parser.add_argument("--output-dir", type=Path, default=Path("data")) - parser.add_argument( - "--dataset-size", - choices=["small", "medium", "large", "xlarge"], - default="medium", - ) - parser.add_argument("--seed", type=int, help="Random seed for reproducibility") - args = parser.parse_args() - - generate_git_files(args.output_dir, args.dataset_size, args.seed) - - -if __name__ == "__main__": - main() + print(f"Git file listing generation complete ({len(datasets)} datasets)") diff --git a/scripts/gen_data/github.py b/code/src/openneuro_dashboard/gen_data/github.py similarity index 78% rename from scripts/gen_data/github.py rename to code/src/openneuro_dashboard/gen_data/github.py index 99d192e2ec..ac56813819 100644 --- a/scripts/gen_data/github.py +++ b/code/src/openneuro_dashboard/gen_data/github.py @@ -1,6 +1,4 @@ -#!/usr/bin/env python3 -""" -Stage 2: Simulate GitHub mirror checks. +"""Stage 2: Generate simulated GitHub mirror check data. Reads: - data/datasets-registry.json @@ -11,14 +9,14 @@ - data/datasets/{id}/github.json """ -import argparse import random from pathlib import Path -from utils import SCHEMA_VERSION, random_datetime, random_sha, write_json, load_json +from ..utils import SCHEMA_VERSION, load_json, write_json +from .utils import random_datetime, random_sha -def generate_github_check( +def _generate_github_check( dataset_id: str, tags: list[str], snapshot_metadata: dict[str, dict], scenario: str ) -> dict: """Generate github.json for a dataset.""" @@ -61,7 +59,7 @@ def generate_github_check( } -def generate_github_checks(output_dir: Path, seed: int = None): +def generate(output_dir: Path, seed: int = None): """Generate GitHub check data for all datasets.""" if seed is not None: random.seed(seed) @@ -91,7 +89,7 @@ def generate_github_checks(output_dir: Path, seed: int = None): ] # Generate and write github.json - github_data = generate_github_check( + github_data = _generate_github_check( dataset_id, tags, snapshot_metadata, scenario ) write_json(dataset_dir / "github.json", github_data) @@ -99,17 +97,4 @@ def generate_github_checks(output_dir: Path, seed: int = None): if i % 100 == 0: print(f" Processed {i}/{len(datasets)}") - print(f"✓ GitHub check generation complete ({len(datasets)} datasets)") - - -def main(): - parser = argparse.ArgumentParser(description="Generate simulated GitHub check data") - parser.add_argument("--output-dir", type=Path, default=Path("data")) - parser.add_argument("--seed", type=int, help="Random seed for reproducibility") - args = parser.parse_args() - - generate_github_checks(args.output_dir, args.seed) - - -if __name__ == "__main__": - main() + print(f"GitHub check generation complete ({len(datasets)} datasets)") diff --git a/scripts/gen_data/graphql.py b/code/src/openneuro_dashboard/gen_data/graphql.py similarity index 60% rename from scripts/gen_data/graphql.py rename to code/src/openneuro_dashboard/gen_data/graphql.py index 65f42e7dc8..4ff2d069b5 100644 --- a/scripts/gen_data/graphql.py +++ b/code/src/openneuro_dashboard/gen_data/graphql.py @@ -1,6 +1,4 @@ -#!/usr/bin/env python3 -""" -Stage 1: Simulate GraphQL fetch. +"""Stage 1: Generate simulated GraphQL fetch data. Generates: - data/datasets-registry.json @@ -8,80 +6,66 @@ - data/datasets/{id}/snapshots/{tag}/metadata.json """ -import argparse import random from pathlib import Path -from utils import ( - SCHEMA_VERSION, random_datetime, random_sha, generate_dataset_id, - generate_snapshots, get_latest_snapshot, write_json +from ..utils import SCHEMA_VERSION, write_json +from .utils import ( + generate_dataset_id, + generate_snapshots, + get_latest_snapshot, + random_datetime, + random_sha, ) -def generate_graphql_data(output_dir: Path, num_datasets: int, seed: int = None): +def generate(output_dir: Path, num_datasets: int = 50, seed: int = None): """Generate simulated GraphQL data.""" if seed is not None: random.seed(seed) - + print(f"Generating GraphQL data for {num_datasets} datasets...") - + latest_snapshots = {} timestamp = random_datetime(days_ago=1) - + for i in range(1, num_datasets + 1): dataset_id = generate_dataset_id(i) tags = generate_snapshots() latest = get_latest_snapshot(tags) - + latest_snapshots[dataset_id] = latest - + # Write snapshots.json dataset_dir = output_dir / "datasets" / dataset_id - snapshots_index = { - "schemaVersion": SCHEMA_VERSION, - "tags": tags - } + snapshots_index = {"schemaVersion": SCHEMA_VERSION, "tags": tags} write_json(dataset_dir / "snapshots.json", snapshots_index) - + # Write metadata.json for each snapshot for idx, tag in enumerate(tags): # Older snapshots have older creation dates days_ago = (len(tags) - idx) * 180 + random.randint(0, 100) - + snapshot_dir = dataset_dir / "snapshots" / tag metadata = { "schemaVersion": SCHEMA_VERSION, "hexsha": random_sha(), - "created": random_datetime(days_ago=days_ago) + "created": random_datetime(days_ago=days_ago), } write_json(snapshot_dir / "metadata.json", metadata) - + if i % 100 == 0: print(f" Processed {i}/{num_datasets}") - + # Write registry registry = { "schemaVersion": SCHEMA_VERSION, "lastChecked": timestamp, "totalCount": num_datasets, - "latestSnapshots": latest_snapshots + "latestSnapshots": latest_snapshots, } write_json(output_dir / "datasets-registry.json", registry) - - print(f"✓ GraphQL data generation complete") + + print(f"GraphQL data generation complete") print(f" Registry: datasets-registry.json ({num_datasets} datasets)") print(f" Per-dataset files: {num_datasets} snapshots.json files") - - -def main(): - parser = argparse.ArgumentParser(description="Generate simulated GraphQL data") - parser.add_argument("--output-dir", type=Path, default=Path("data")) - parser.add_argument("--num-datasets", type=int, default=20) - parser.add_argument("--seed", type=int, help="Random seed for reproducibility") - args = parser.parse_args() - - generate_graphql_data(args.output_dir, args.num_datasets, args.seed) - - -if __name__ == "__main__": - main() diff --git a/scripts/gen_data/s3_diff.py b/code/src/openneuro_dashboard/gen_data/s3_diff.py similarity index 71% rename from scripts/gen_data/s3_diff.py rename to code/src/openneuro_dashboard/gen_data/s3_diff.py index 688b1af0ca..cc38ba06cc 100644 --- a/scripts/gen_data/s3_diff.py +++ b/code/src/openneuro_dashboard/gen_data/s3_diff.py @@ -1,6 +1,4 @@ -#!/usr/bin/env python3 -""" -Stage 5: Simulate S3 file diff. +"""Stage 5: Generate simulated S3 file diff data. Reads: - data/datasets-registry.json @@ -11,26 +9,30 @@ - data/datasets/{id}/s3-diff.json (only if S3 version matches latest) """ -import argparse import random from pathlib import Path -from utils import SCHEMA_VERSION, random_datetime, write_json, load_json +from ..utils import SCHEMA_VERSION, load_json, write_json +from .utils import random_datetime -def compute_context(sorted_files: list[str], changed: set[str], radius: int = 3) -> list[str]: +def _compute_context( + sorted_files: list[str], changed: set[str], radius: int = 3 +) -> list[str]: """Compute context files within `radius` positions of any changed file.""" context = set() for i, f in enumerate(sorted_files): if f in changed: - for j in range(max(0, i - radius), min(len(sorted_files), i + radius + 1)): + for j in range( + max(0, i - radius), min(len(sorted_files), i + radius + 1) + ): neighbor = sorted_files[j] if neighbor not in changed: context.add(neighbor) return sorted(context) -def generate_s3_diff( +def _generate_s3_diff( dataset_id: str, version: str, git_files: list[str], scenario: str ) -> dict: """Generate s3-diff.json in v1.1.0 format.""" @@ -41,9 +43,9 @@ def generate_s3_diff( elif scenario == "error": num_missing = random.randint(1, min(5, max(1, len(git_files) // 10))) added = sorted(random.sample(git_files, num_missing)) - removed = sorted([".DS_Store", "._sub-01_T1w.nii.gz", "Thumbs.db"][ - : random.randint(1, 3) - ]) + removed = sorted( + [".DS_Store", "._sub-01_T1w.nii.gz", "Thumbs.db"][: random.randint(1, 3)] + ) total_s3 = len(git_files) - len(added) + len(removed) else: # warning — treat as error in new schema (any diff = error) added = sorted([random.choice(git_files)]) @@ -51,7 +53,7 @@ def generate_s3_diff( total_s3 = len(git_files) - 1 changed = set(added) | set(removed) - context = compute_context(git_files, changed) if changed else [] + context = _compute_context(git_files, changed) if changed else [] return { "schemaVersion": SCHEMA_VERSION, @@ -69,7 +71,7 @@ def generate_s3_diff( } -def generate_s3_diffs(output_dir: Path, seed: int = None): +def generate(output_dir: Path, seed: int = None): """Generate S3 diff data for all datasets where version matches.""" if seed is not None: random.seed(seed) @@ -89,7 +91,7 @@ def generate_s3_diffs(output_dir: Path, seed: int = None): # Load S3 version s3_version_path = dataset_dir / "s3-version.json" if not s3_version_path.exists(): - print(f"⚠ {dataset_id}: s3-version.json not found, skipping") + print(f" {dataset_id}: s3-version.json not found, skipping") skipped += 1 continue @@ -103,7 +105,7 @@ def generate_s3_diffs(output_dir: Path, seed: int = None): # Load git files files_path = dataset_dir / "snapshots" / latest_snapshot / "files.json" if not files_path.exists(): - print(f"⚠ {dataset_id}: files.json not found, skipping") + print(f" {dataset_id}: files.json not found, skipping") skipped += 1 continue @@ -115,26 +117,15 @@ def generate_s3_diffs(output_dir: Path, seed: int = None): ] # Generate and write s3-diff.json - s3_diff = generate_s3_diff(dataset_id, latest_snapshot, files_data["files"], scenario) + s3_diff = _generate_s3_diff( + dataset_id, latest_snapshot, files_data["files"], scenario + ) write_json(dataset_dir / "s3-diff.json", s3_diff) generated += 1 if i % 100 == 0: print(f" Processed {i}/{len(datasets)}") - print("✓ S3 diff generation complete") + print("S3 diff generation complete") print(f" Generated: {generated} diffs") print(f" Skipped: {skipped} (version mismatch or missing data)") - - -def main(): - parser = argparse.ArgumentParser(description="Generate simulated S3 diff data") - parser.add_argument("--output-dir", type=Path, default=Path("data")) - parser.add_argument("--seed", type=int, help="Random seed for reproducibility") - args = parser.parse_args() - - generate_s3_diffs(args.output_dir, args.seed) - - -if __name__ == "__main__": - main() diff --git a/scripts/gen_data/s3_version.py b/code/src/openneuro_dashboard/gen_data/s3_version.py similarity index 72% rename from scripts/gen_data/s3_version.py rename to code/src/openneuro_dashboard/gen_data/s3_version.py index 04b6e5688d..9606d3a685 100644 --- a/scripts/gen_data/s3_version.py +++ b/code/src/openneuro_dashboard/gen_data/s3_version.py @@ -1,6 +1,4 @@ -#!/usr/bin/env python3 -""" -Stage 3: Simulate S3 version checks. +"""Stage 3: Generate simulated S3 version check data. Reads: - data/datasets-registry.json @@ -9,16 +7,17 @@ - data/datasets/{id}/s3-version.json """ -import argparse import random from pathlib import Path -from utils import SCHEMA_VERSION, random_datetime, write_json, load_json +from ..utils import SCHEMA_VERSION, load_json, write_json +from .utils import random_datetime -def generate_s3_version_check(dataset_id: str, latest_snapshot: str, scenario: str) -> dict: +def _generate_s3_version_check( + dataset_id: str, latest_snapshot: str, scenario: str +) -> dict: """Generate s3-version.json for a dataset.""" - # Case 3: Blocked (403) if scenario == "blocked": return { @@ -27,9 +26,9 @@ def generate_s3_version_check(dataset_id: str, latest_snapshot: str, scenario: s "accessible": False, "httpStatus": 403, "datasetDescriptionDOI": None, - "extractedVersion": None + "extractedVersion": None, } - + # Case 4: Not found (404) if scenario == "not_found": return { @@ -39,9 +38,9 @@ def generate_s3_version_check(dataset_id: str, latest_snapshot: str, scenario: s "datasetDescriptionDOI": None, "extractedVersion": latest_snapshot, "versionSource": "assumed_latest", - "datasetDescriptionMissing": True + "datasetDescriptionMissing": True, } - + # Case 2: Missing or custom DOI if scenario == "custom_doi": doi_type = random.choice(["custom", "missing"]) @@ -53,7 +52,7 @@ def generate_s3_version_check(dataset_id: str, latest_snapshot: str, scenario: s "accessible": True, "datasetDescriptionDOI": custom_doi, "extractedVersion": latest_snapshot, - "versionSource": "assumed_latest" + "versionSource": "assumed_latest", } else: return { @@ -62,21 +61,21 @@ def generate_s3_version_check(dataset_id: str, latest_snapshot: str, scenario: s "accessible": True, "datasetDescriptionDOI": None, "extractedVersion": latest_snapshot, - "versionSource": "assumed_latest" + "versionSource": "assumed_latest", } - + # Case 1: Normal DOI or version mismatch actual_version = latest_snapshot - + # For version-mismatch scenario, use an older version if scenario == "version-mismatch": - parts = latest_snapshot.split('.') + parts = latest_snapshot.split(".") if len(parts) == 3 and parts[2].isdigit() and int(parts[2]) > 0: parts[2] = str(int(parts[2]) - 1) - actual_version = '.'.join(parts) - + actual_version = ".".join(parts) + doi = f"10.18112/openneuro.{dataset_id}.v{actual_version}" - + # Occasionally add DOI ID mismatch if random.random() < 0.01: # 1% chance wrong_id = f"ds{random.randint(0, 999999):06d}" @@ -89,57 +88,46 @@ def generate_s3_version_check(dataset_id: str, latest_snapshot: str, scenario: s "extractedVersion": actual_version, "versionSource": "doi", "doiIdMismatch": True, - "doiDatasetId": wrong_id + "doiDatasetId": wrong_id, } - + return { "schemaVersion": SCHEMA_VERSION, "lastChecked": random_datetime(days_ago=1), "accessible": True, "datasetDescriptionDOI": doi, "extractedVersion": actual_version, - "versionSource": "doi" + "versionSource": "doi", } -def generate_s3_version_checks(output_dir: Path, seed: int = None): +def generate(output_dir: Path, seed: int = None): """Generate S3 version check data for all datasets.""" if seed is not None: random.seed(seed) - + print("Generating S3 version check data...") - + # Load registry registry = load_json(output_dir / "datasets-registry.json") datasets = registry["latestSnapshots"] - + for i, (dataset_id, latest_snapshot) in enumerate(datasets.items(), 1): dataset_dir = output_dir / "datasets" / dataset_id - + # Determine scenario with realistic weights scenario = random.choices( ["healthy", "version-mismatch", "custom_doi", "not_found", "blocked"], - weights=[75, 10, 8, 5, 2] + weights=[75, 10, 8, 5, 2], )[0] - + # Generate s3-version.json - s3_version_data = generate_s3_version_check(dataset_id, latest_snapshot, scenario) + s3_version_data = _generate_s3_version_check( + dataset_id, latest_snapshot, scenario + ) write_json(dataset_dir / "s3-version.json", s3_version_data) - + if i % 100 == 0: print(f" Processed {i}/{len(datasets)}") - - print(f"✓ S3 version check generation complete ({len(datasets)} datasets)") - - -def main(): - parser = argparse.ArgumentParser(description="Generate simulated S3 version check data") - parser.add_argument("--output-dir", type=Path, default=Path("data")) - parser.add_argument("--seed", type=int, help="Random seed for reproducibility") - args = parser.parse_args() - - generate_s3_version_checks(args.output_dir, args.seed) - -if __name__ == "__main__": - main() + print(f"S3 version check generation complete ({len(datasets)} datasets)") diff --git a/scripts/gen_data/utils.py b/code/src/openneuro_dashboard/gen_data/utils.py similarity index 85% rename from scripts/gen_data/utils.py rename to code/src/openneuro_dashboard/gen_data/utils.py index 3670483268..1221ca56d1 100644 --- a/scripts/gen_data/utils.py +++ b/code/src/openneuro_dashboard/gen_data/utils.py @@ -1,11 +1,9 @@ """Shared utilities for test data generation.""" -import json import random -from pathlib import Path -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta -SCHEMA_VERSION = "1.0.0" +from ..utils import SCHEMA_VERSION, write_json # noqa: F401 def random_sha() -> str: @@ -15,7 +13,7 @@ def random_sha() -> str: def random_datetime(days_ago: int = 30) -> str: """Generate a random datetime within the last N days.""" - dt = datetime.now() - timedelta(days=random.randint(0, days_ago)) + dt = datetime.now(UTC) - timedelta(days=random.randint(0, days_ago)) return dt.strftime("%Y-%m-%dT%H:%M:%S.000Z") @@ -105,17 +103,3 @@ def generate_file_paths(size: str = "medium") -> list[str]: ) return sorted(set(files))[:num_files] - - -def write_json(path: Path, data: dict) -> None: - """Write JSON to file with pretty formatting.""" - path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w") as f: - json.dump(data, f, indent=2) - f.write("\n") - - -def load_json(path: Path) -> dict: - """Load JSON from file.""" - with open(path) as f: - return json.load(f) diff --git a/scripts/summarize.py b/code/src/openneuro_dashboard/summarize.py similarity index 64% rename from scripts/summarize.py rename to code/src/openneuro_dashboard/summarize.py index 5f103d7a39..5ed26974fb 100644 --- a/scripts/summarize.py +++ b/code/src/openneuro_dashboard/summarize.py @@ -1,81 +1,51 @@ -#!/usr/bin/env python3 -""" -Stage 6: Summarize check results. +"""Stage 6: Summarize check results.""" -Reads: -- data/datasets-registry.json -- data/datasets/{id}/github.json -- data/datasets/{id}/s3-version.json -- data/datasets/{id}/s3-diff.json - -Writes: -- data/all-datasets.json -""" - -import argparse from pathlib import Path -from utils import SCHEMA_VERSION, write_json, load_json, format_timestamp - +from .utils import SCHEMA_VERSION, format_timestamp, load_json, load_json_safe, write_json -def load_json_safe(path: Path) -> dict | None: - """Load JSON file if it exists, return None otherwise.""" - if path.exists(): - return load_json(path) - return None - -def summarize_dataset( - dataset_id: str, - latest_snapshot: str, - dataset_dir: Path -) -> dict: +def summarize_dataset(dataset_id, latest_snapshot, dataset_dir) -> dict: """Summarize check results for a single dataset.""" checks = {} last_checked = {} - + # GitHub check github = load_json_safe(dataset_dir / "github.json") if not github: checks["github"] = "pending" elif latest_snapshot not in github.get("tags", {}): checks["github"] = "error" - elif github["branches"].get(github["head"]) != github["tags"].get(latest_snapshot): + elif github["branches"].get(github["head"]) != github["tags"].get( + latest_snapshot + ): checks["github"] = "warning" else: checks["github"] = "ok" - if github: last_checked["github"] = github["lastChecked"] - + # S3 version check s3_version = load_json_safe(dataset_dir / "s3-version.json") - s3_blocked = False # Track if S3 is blocked by 403 - + s3_blocked = False if not s3_version: checks["s3Version"] = "pending" checks["s3Files"] = "pending" elif not s3_version.get("accessible", True): - # Case 3: Blocked (403) - both checks are blocked checks["s3Version"] = "error" checks["s3Files"] = "error" s3_blocked = True last_checked["s3Version"] = s3_version["lastChecked"] else: - # Accessible (cases 1, 2, 4) if s3_version.get("versionSource") == "doi": - # Case 1: Check if version matches if s3_version["extractedVersion"] == latest_snapshot: checks["s3Version"] = "ok" else: checks["s3Version"] = "version-mismatch" else: - # Case 2 or 4: Assumed latest (flag as warning) checks["s3Version"] = "warning" - last_checked["s3Version"] = s3_version["lastChecked"] - - # S3 files check + s3_diff = load_json_safe(dataset_dir / "s3-diff.json") if not s3_diff: checks["s3Files"] = "pending" @@ -85,79 +55,57 @@ def summarize_dataset( else: checks["s3Files"] = s3_diff.get("status", "pending") last_checked["s3Files"] = s3_diff["checkedAt"] - - # Overall status (worst of all checks) - status_priority = {"ok": 0, "warning": 1, "version-mismatch": 2, "error": 3, "pending": 4} - overall_status = max(checks.values(), key=lambda s: status_priority[s]) - - result = { - "id": dataset_id, - "status": overall_status, - "checks": checks + + status_priority = { + "ok": 0, + "warning": 1, + "version-mismatch": 2, + "error": 3, + "pending": 4, } - - # Add s3Blocked flag if applicable + overall_status = max(checks.values(), key=lambda s: status_priority[s]) + + result = {"id": dataset_id, "status": overall_status, "checks": checks} if s3_blocked: result["s3Blocked"] = True - if last_checked: result["lastChecked"] = last_checked - return result def generate_summary(output_dir: Path): """Generate summary from all check files.""" print("Generating summary...") - - # Load registry registry = load_json(output_dir / "datasets-registry.json") datasets_dict = registry["latestSnapshots"] - - # Summarize each dataset + datasets = [] for dataset_id, latest_snapshot in datasets_dict.items(): dataset_dir = output_dir / "datasets" / dataset_id summary = summarize_dataset(dataset_id, latest_snapshot, dataset_dir) datasets.append(summary) - - # Create summary document + summary_doc = { "schemaVersion": SCHEMA_VERSION, "lastUpdated": format_timestamp(), - "datasets": datasets + "datasets": datasets, } - write_json(output_dir / "all-datasets.json", summary_doc) - + # Print statistics status_counts = {} s3_blocked_count = 0 - for ds in datasets: status = ds["status"] status_counts[status] = status_counts.get(status, 0) + 1 if ds.get("s3Blocked"): s3_blocked_count += 1 - - print(f"\n✓ Summary generation complete ({len(datasets)} datasets)") + + print(f"\nSummary generation complete ({len(datasets)} datasets)") print("\nDataset status breakdown:") for status in ["ok", "warning", "version-mismatch", "error", "pending"]: count = status_counts.get(status, 0) if count > 0: print(f" {status}: {count}") - if s3_blocked_count > 0: print(f"\nS3 blocked (403): {s3_blocked_count} datasets") - - -def main(): - parser = argparse.ArgumentParser(description="Generate summary from check results") - parser.add_argument("--output-dir", type=Path, default=Path("data")) - args = parser.parse_args() - - generate_summary(args.output_dir) - - -if __name__ == "__main__": - main() diff --git a/code/src/openneuro_dashboard/utils.py b/code/src/openneuro_dashboard/utils.py new file mode 100644 index 0000000000..188d7b1975 --- /dev/null +++ b/code/src/openneuro_dashboard/utils.py @@ -0,0 +1,33 @@ +"""Dashboard pipeline utilities.""" + +import json +from datetime import UTC, datetime +from pathlib import Path + +SCHEMA_VERSION = "1.1.0" + + +def format_timestamp() -> str: + """Format current UTC time in the standard dashboard timestamp format.""" + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z") + + +def write_json(path: Path, data: dict) -> None: + """Write JSON to file with pretty formatting.""" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") + + +def load_json(path: Path) -> dict: + """Load JSON from file.""" + with open(path) as f: + return json.load(f) + + +def load_json_safe(path: Path) -> dict | None: + """Load JSON from file if it exists, otherwise return None.""" + if not path.exists(): + return None + return load_json(path) diff --git a/code/tests/__init__.py b/code/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/code/tests/conftest.py b/code/tests/conftest.py new file mode 100644 index 0000000000..8b0e66e2d1 --- /dev/null +++ b/code/tests/conftest.py @@ -0,0 +1,186 @@ +"""Shared test fixtures for the OpenNeuro Dashboard test suite.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Canned dataset metadata (3 datasets with different health states) +# --------------------------------------------------------------------------- + +HEALTHY_DATASET = { + "id": "ds000001", + "name": "Healthy Dataset", + "public": True, + "created": "2020-01-15T00:00:00.000Z", + "uploader": {"name": "Test User"}, + "latestSnapshot": { + "tag": "1.0.2", + "created": "2024-06-01T12:00:00.000Z", + "description": { + "Name": "Healthy Dataset", + "BIDSVersion": "1.9.0", + }, + "size": 1024000, + "summary": {"subjects": 20, "sessions": 1, "tasks": ["rest"]}, + }, +} + +BLOCKED_DATASET = { + "id": "ds000002", + "name": "Blocked Dataset", + "public": True, + "created": "2021-03-10T00:00:00.000Z", + "uploader": {"name": "Test User 2"}, + "latestSnapshot": None, +} + +VERSION_MISMATCH_DATASET = { + "id": "ds000003", + "name": "Version Mismatch Dataset", + "public": True, + "created": "2022-07-20T00:00:00.000Z", + "uploader": {"name": "Test User 3"}, + "latestSnapshot": { + "tag": "1.1.0", + "created": "2025-01-10T08:30:00.000Z", + "description": { + "Name": "Version Mismatch Dataset", + "BIDSVersion": "1.8.0", + }, + "size": 5120000, + "summary": {"subjects": 5, "sessions": 2, "tasks": ["nback", "rest"]}, + }, +} + +ALL_DATASETS = [HEALTHY_DATASET, BLOCKED_DATASET, VERSION_MISMATCH_DATASET] + + +# --------------------------------------------------------------------------- +# GraphQL fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def graphql_datasets(): + """Return canned GraphQL dataset responses.""" + return ALL_DATASETS + + +@pytest.fixture() +def mock_graphql_response(): + """Return a mock HTTP response for a GraphQL datasets query.""" + return { + "data": { + "datasets": { + "edges": [{"node": ds} for ds in ALL_DATASETS], + "pageInfo": {"hasNextPage": False, "endCursor": "cursor_end"}, + } + } + } + + +# --------------------------------------------------------------------------- +# Git fixtures +# --------------------------------------------------------------------------- + +GIT_REFS = { + "ds000001": { + "refs/heads/main": "aaa1111111111111111111111111111111111111", + "refs/tags/1.0.2": "aaa2222222222222222222222222222222222222", + }, + "ds000002": { + "refs/heads/main": "bbb1111111111111111111111111111111111111", + }, + "ds000003": { + "refs/heads/main": "ccc1111111111111111111111111111111111111", + "refs/tags/1.1.0": "ccc2222222222222222222222222222222222222", + "refs/tags/1.0.0": "ccc3333333333333333333333333333333333333", + }, +} + + +@pytest.fixture() +def git_refs(): + """Return canned git ls-remote ref maps keyed by dataset id.""" + return GIT_REFS + + +@pytest.fixture() +def mock_git_ls_remote(): + """Patch subprocess to return canned ls-remote output for any dataset.""" + outputs = {} + for ds_id, refs in GIT_REFS.items(): + lines = "\n".join(f"{sha}\t{ref}" for ref, sha in refs.items()) + outputs[ds_id] = lines + + async def _fake_ls_remote(url: str, *_args, **_kwargs): + for ds_id in GIT_REFS: + if ds_id in url: + return outputs[ds_id] + return "" + + with patch( + "openneuro_dashboard._git_ls_remote", + new_callable=lambda: lambda: _fake_ls_remote, + create=True, + ) as mock: + yield mock + + +# --------------------------------------------------------------------------- +# S3 / aioboto3 fixtures +# --------------------------------------------------------------------------- + +S3_FILE_LISTINGS = { + "ds000001": [ + {"Key": "dataset_description.json", "Size": 256}, + {"Key": "participants.tsv", "Size": 1024}, + {"Key": "sub-01/anat/sub-01_T1w.nii.gz", "Size": 50000}, + ], + "ds000002": [], + "ds000003": [ + {"Key": "dataset_description.json", "Size": 300}, + {"Key": "participants.tsv", "Size": 512}, + ], +} + +S3_DATASET_DESCRIPTIONS = { + "ds000001": '{"Name": "Healthy Dataset", "BIDSVersion": "1.9.0"}', + "ds000003": '{"Name": "Version Mismatch Dataset", "BIDSVersion": "1.8.0"}', +} + + +@pytest.fixture() +def s3_file_listings(): + """Return canned S3 file listings keyed by dataset id.""" + return S3_FILE_LISTINGS + + +@pytest.fixture() +def s3_dataset_descriptions(): + """Return canned dataset_description.json content keyed by dataset id.""" + return S3_DATASET_DESCRIPTIONS + + +@pytest.fixture() +def mock_s3_client(): + """Provide a mock aioboto3 S3 client with canned responses.""" + client = AsyncMock() + + async def _list_objects(Bucket, Prefix="", **_kwargs): # noqa: N803 + ds_id = Prefix.rstrip("/") + contents = S3_FILE_LISTINGS.get(ds_id, []) + return {"Contents": contents} if contents else {} + + async def _get_object(Bucket, Key, **_kwargs): # noqa: N803 + ds_id = Key.split("/")[0] if "/" in Key else "" + body_text = S3_DATASET_DESCRIPTIONS.get(ds_id, "{}") + body = AsyncMock() + body.read = AsyncMock(return_value=body_text.encode()) + return {"Body": body} + + client.list_objects_v2 = AsyncMock(side_effect=_list_objects) + client.get_object = AsyncMock(side_effect=_get_object) + + return client diff --git a/code/tests/fixtures/.gitkeep b/code/tests/fixtures/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/code/tests/fixtures/all-datasets.json b/code/tests/fixtures/all-datasets.json new file mode 100644 index 0000000000..da0fe681a5 --- /dev/null +++ b/code/tests/fixtures/all-datasets.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": "1.1.0", + "datasets": [ + { + "id": "ds000001", + "status": "ok", + "checks": { + "github": "ok", + "s3Version": "ok", + "s3Files": "ok" + } + }, + { + "id": "ds000002", + "status": "error", + "s3Blocked": true, + "checks": { + "github": "ok", + "s3Version": "error", + "s3Files": "error" + } + }, + { + "id": "ds000003", + "status": "version-mismatch", + "checks": { + "github": "ok", + "s3Version": "version-mismatch", + "s3Files": "ok" + } + }, + { + "id": "ds000004", + "status": "warning", + "checks": { + "github": "warning", + "s3Version": "warning", + "s3Files": "ok" + } + }, + { + "id": "ds000005", + "status": "error", + "checks": { + "github": "ok", + "s3Version": "ok", + "s3Files": "error" + } + } + ] +} diff --git a/code/tests/fixtures/datasets-registry.json b/code/tests/fixtures/datasets-registry.json new file mode 100644 index 0000000000..fb5c77b526 --- /dev/null +++ b/code/tests/fixtures/datasets-registry.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "totalCount": 5, + "latestSnapshots": { + "ds000001": "1.0.2", + "ds000002": "2.0.0", + "ds000003": "1.1.0", + "ds000004": "1.0.0", + "ds000005": "1.0.0" + } +} diff --git a/code/tests/fixtures/datasets/ds000001/github.json b/code/tests/fixtures/datasets/ds000001/github.json new file mode 100644 index 0000000000..86f84cc026 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000001/github.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000001", + "head": "main", + "branches": { + "main": "aaa1111111111111111111111111111111111111" + }, + "tags": { + "1.0.2": "aaa1111111111111111111111111111111111111", + "1.0.1": "bbb2222222222222222222222222222222222222", + "1.0.0": "ccc3333333333333333333333333333333333333" + } +} diff --git a/code/tests/fixtures/datasets/ds000001/s3-diff.json b/code/tests/fixtures/datasets/ds000001/s3-diff.json new file mode 100644 index 0000000000..46d5955ce8 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000001/s3-diff.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "1.1.0", + "datasetId": "ds000001", + "snapshotTag": "1.0.2", + "s3Version": "1.0.2", + "checkedAt": "2026-01-01T00:00:00.000Z", + "status": "ok", + "exportMissing": false, + "totalS3Files": 10, + "totalGitFiles": 10, + "added": [], + "removed": [], + "context": [] +} diff --git a/code/tests/fixtures/datasets/ds000001/s3-version.json b/code/tests/fixtures/datasets/ds000001/s3-version.json new file mode 100644 index 0000000000..fcedc4b0b0 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000001/s3-version.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000001", + "accessible": true, + "datasetDescriptionDOI": "10.18112/openneuro.ds000001.v1.0.2", + "extractedVersion": "1.0.2", + "versionSource": "doi" +} diff --git a/code/tests/fixtures/datasets/ds000001/snapshots.json b/code/tests/fixtures/datasets/ds000001/snapshots.json new file mode 100644 index 0000000000..3950f04dd3 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000001/snapshots.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000001", + "tags": [ + "1.0.0", + "1.0.1", + "1.0.2" + ] +} diff --git a/code/tests/fixtures/datasets/ds000002/github.json b/code/tests/fixtures/datasets/ds000002/github.json new file mode 100644 index 0000000000..d31c4256d0 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000002/github.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000002", + "head": "main", + "branches": { + "main": "ddd4444444444444444444444444444444444444" + }, + "tags": { + "2.0.0": "ddd4444444444444444444444444444444444444", + "1.0.0": "eee5555555555555555555555555555555555555" + } +} diff --git a/code/tests/fixtures/datasets/ds000002/s3-version.json b/code/tests/fixtures/datasets/ds000002/s3-version.json new file mode 100644 index 0000000000..dea52350cf --- /dev/null +++ b/code/tests/fixtures/datasets/ds000002/s3-version.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000002", + "accessible": false, + "httpStatus": 403 +} diff --git a/code/tests/fixtures/datasets/ds000002/snapshots.json b/code/tests/fixtures/datasets/ds000002/snapshots.json new file mode 100644 index 0000000000..c1f07219b7 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000002/snapshots.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000002", + "tags": [ + "1.0.0", + "2.0.0" + ] +} diff --git a/code/tests/fixtures/datasets/ds000003/github.json b/code/tests/fixtures/datasets/ds000003/github.json new file mode 100644 index 0000000000..1ec61fe9cb --- /dev/null +++ b/code/tests/fixtures/datasets/ds000003/github.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000003", + "head": "main", + "branches": { + "main": "fff6666666666666666666666666666666666666" + }, + "tags": { + "1.1.0": "fff6666666666666666666666666666666666666", + "1.0.0": "ggg7777777777777777777777777777777777777" + } +} diff --git a/code/tests/fixtures/datasets/ds000003/s3-diff.json b/code/tests/fixtures/datasets/ds000003/s3-diff.json new file mode 100644 index 0000000000..58fc490df7 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000003/s3-diff.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "1.1.0", + "datasetId": "ds000003", + "snapshotTag": "1.0.0", + "s3Version": "1.0.0", + "checkedAt": "2026-01-01T00:00:00.000Z", + "status": "ok", + "exportMissing": false, + "totalS3Files": 5, + "totalGitFiles": 5, + "added": [], + "removed": [], + "context": [] +} diff --git a/code/tests/fixtures/datasets/ds000003/s3-version.json b/code/tests/fixtures/datasets/ds000003/s3-version.json new file mode 100644 index 0000000000..749ef8f254 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000003/s3-version.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000003", + "accessible": true, + "datasetDescriptionDOI": "10.18112/openneuro.ds000003.v1.0.0", + "extractedVersion": "1.0.0", + "versionSource": "doi" +} diff --git a/code/tests/fixtures/datasets/ds000003/snapshots.json b/code/tests/fixtures/datasets/ds000003/snapshots.json new file mode 100644 index 0000000000..1122a827ab --- /dev/null +++ b/code/tests/fixtures/datasets/ds000003/snapshots.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000003", + "tags": [ + "1.0.0", + "1.1.0" + ] +} diff --git a/code/tests/fixtures/datasets/ds000004/github.json b/code/tests/fixtures/datasets/ds000004/github.json new file mode 100644 index 0000000000..a591d21805 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000004/github.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000004", + "head": "main", + "branches": { + "main": "xxx8888888888888888888888888888888888888" + }, + "tags": { + "1.0.0": "yyy9999999999999999999999999999999999999" + } +} diff --git a/code/tests/fixtures/datasets/ds000004/s3-diff.json b/code/tests/fixtures/datasets/ds000004/s3-diff.json new file mode 100644 index 0000000000..dcc3da7d5c --- /dev/null +++ b/code/tests/fixtures/datasets/ds000004/s3-diff.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "1.1.0", + "datasetId": "ds000004", + "snapshotTag": "1.0.0", + "s3Version": "1.0.0", + "checkedAt": "2026-01-01T00:00:00.000Z", + "status": "ok", + "exportMissing": false, + "totalS3Files": 3, + "totalGitFiles": 3, + "added": [], + "removed": [], + "context": [] +} diff --git a/code/tests/fixtures/datasets/ds000004/s3-version.json b/code/tests/fixtures/datasets/ds000004/s3-version.json new file mode 100644 index 0000000000..418e8c8b8d --- /dev/null +++ b/code/tests/fixtures/datasets/ds000004/s3-version.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000004", + "accessible": true, + "extractedVersion": "1.0.0", + "versionSource": "assumed_latest" +} diff --git a/code/tests/fixtures/datasets/ds000004/snapshots.json b/code/tests/fixtures/datasets/ds000004/snapshots.json new file mode 100644 index 0000000000..7b5dc84f75 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000004/snapshots.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000004", + "tags": [ + "1.0.0" + ] +} diff --git a/code/tests/fixtures/datasets/ds000005/github.json b/code/tests/fixtures/datasets/ds000005/github.json new file mode 100644 index 0000000000..3dd51b2cc5 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000005/github.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000005", + "head": "main", + "branches": { + "main": "zzz0000000000000000000000000000000000000" + }, + "tags": { + "1.0.0": "zzz0000000000000000000000000000000000000" + } +} diff --git a/code/tests/fixtures/datasets/ds000005/s3-diff.json b/code/tests/fixtures/datasets/ds000005/s3-diff.json new file mode 100644 index 0000000000..e9ff7bebe3 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000005/s3-diff.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": "1.1.0", + "datasetId": "ds000005", + "snapshotTag": "1.0.0", + "s3Version": "1.0.0", + "checkedAt": "2026-01-01T00:00:00.000Z", + "status": "error", + "exportMissing": true, + "totalS3Files": 0, + "totalGitFiles": 8, + "added": [ + "file1.nii.gz", + "file2.nii.gz", + "file3.nii.gz", + "file4.nii.gz", + "file5.nii.gz", + "file6.nii.gz", + "file7.nii.gz", + "file8.nii.gz" + ], + "removed": [], + "context": [] +} diff --git a/code/tests/fixtures/datasets/ds000005/s3-version.json b/code/tests/fixtures/datasets/ds000005/s3-version.json new file mode 100644 index 0000000000..113484f510 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000005/s3-version.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000005", + "accessible": true, + "datasetDescriptionDOI": "10.18112/openneuro.ds000005.v1.0.0", + "extractedVersion": "1.0.0", + "versionSource": "doi" +} diff --git a/code/tests/fixtures/datasets/ds000005/snapshots.json b/code/tests/fixtures/datasets/ds000005/snapshots.json new file mode 100644 index 0000000000..d029a56502 --- /dev/null +++ b/code/tests/fixtures/datasets/ds000005/snapshots.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": "1.1.0", + "lastChecked": "2026-01-01T00:00:00.000Z", + "datasetId": "ds000005", + "tags": [ + "1.0.0" + ] +} diff --git a/code/tests/integration/__init__.py b/code/tests/integration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/code/tests/integration/test_pipeline.py b/code/tests/integration/test_pipeline.py new file mode 100644 index 0000000000..2e2462772b --- /dev/null +++ b/code/tests/integration/test_pipeline.py @@ -0,0 +1,120 @@ +"""Integration test: run summarize against hand-crafted fixture data.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from openneuro_dashboard.summarize import generate_summary + +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" + +EXPECTED_STATUSES = { + "ds000001": "ok", + "ds000002": "error", + "ds000003": "version-mismatch", + "ds000004": "warning", + "ds000005": "error", +} + +EXPECTED_CHECKS = { + "ds000001": {"github": "ok", "s3Version": "ok", "s3Files": "ok"}, + "ds000002": {"github": "ok", "s3Version": "error", "s3Files": "error"}, + "ds000003": {"github": "ok", "s3Version": "version-mismatch", "s3Files": "ok"}, + "ds000004": {"github": "warning", "s3Version": "warning", "s3Files": "ok"}, + "ds000005": {"github": "ok", "s3Version": "ok", "s3Files": "error"}, +} + + +def _setup_data(tmp_path: Path) -> Path: + """Copy fixtures into a tmp_path/data directory and return it.""" + data_dir = tmp_path / "data" + shutil.copytree(FIXTURES_DIR, data_dir, dirs_exist_ok=True) + return data_dir + + +def test_full_pipeline_summary(tmp_path): + """Run summarize against fixture data and verify statuses.""" + data_dir = _setup_data(tmp_path) + generate_summary(data_dir) + + result = json.loads((data_dir / "all-datasets.json").read_text()) + + assert result["schemaVersion"] == "1.1.0" + assert len(result["datasets"]) == 5 + + for ds in result["datasets"]: + expected = EXPECTED_STATUSES[ds["id"]] + assert ds["status"] == expected, ( + f"{ds['id']}: expected status {expected!r}, got {ds['status']!r}" + ) + + +def test_per_check_statuses(tmp_path): + """Verify individual check statuses for every dataset.""" + data_dir = _setup_data(tmp_path) + generate_summary(data_dir) + + result = json.loads((data_dir / "all-datasets.json").read_text()) + + for ds in result["datasets"]: + expected = EXPECTED_CHECKS[ds["id"]] + assert ds["checks"] == expected, ( + f"{ds['id']}: expected checks {expected}, got {ds['checks']}" + ) + + +def test_blocked_dataset_has_flag(tmp_path): + """Verify s3Blocked flag is set for blocked datasets.""" + data_dir = _setup_data(tmp_path) + generate_summary(data_dir) + + result = json.loads((data_dir / "all-datasets.json").read_text()) + ds002 = next(d for d in result["datasets"] if d["id"] == "ds000002") + assert ds002.get("s3Blocked") is True + + # Non-blocked datasets should not have s3Blocked + for ds in result["datasets"]: + if ds["id"] != "ds000002": + assert "s3Blocked" not in ds, ( + f"{ds['id']} should not have s3Blocked flag" + ) + + +def test_last_checked_timestamps_present(tmp_path): + """Verify lastChecked sub-object is populated from fixture timestamps.""" + data_dir = _setup_data(tmp_path) + generate_summary(data_dir) + + result = json.loads((data_dir / "all-datasets.json").read_text()) + + for ds in result["datasets"]: + assert "lastChecked" in ds, f"{ds['id']} missing lastChecked" + lc = ds["lastChecked"] + # All fixtures that have check files should have github timestamp + assert "github" in lc, f"{ds['id']} missing lastChecked.github" + + +def test_expected_fixture_matches(tmp_path): + """Cross-check generated summary against the expected fixture file.""" + data_dir = _setup_data(tmp_path) + generate_summary(data_dir) + + result = json.loads((data_dir / "all-datasets.json").read_text()) + expected = json.loads((FIXTURES_DIR / "all-datasets.json").read_text()) + + # Compare structural fields (ignore lastUpdated since it uses wallclock) + assert result["schemaVersion"] == expected["schemaVersion"] + assert len(result["datasets"]) == len(expected["datasets"]) + + result_by_id = {ds["id"]: ds for ds in result["datasets"]} + expected_by_id = {ds["id"]: ds for ds in expected["datasets"]} + + for ds_id in expected_by_id: + assert result_by_id[ds_id]["status"] == expected_by_id[ds_id]["status"] + assert result_by_id[ds_id]["checks"] == expected_by_id[ds_id]["checks"] + if "s3Blocked" in expected_by_id[ds_id]: + assert result_by_id[ds_id].get("s3Blocked") == expected_by_id[ds_id]["s3Blocked"] diff --git a/code/tests/unit/__init__.py b/code/tests/unit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/code/tests/unit/test_check_s3_files.py b/code/tests/unit/test_check_s3_files.py new file mode 100644 index 0000000000..e888119ad4 --- /dev/null +++ b/code/tests/unit/test_check_s3_files.py @@ -0,0 +1,107 @@ +"""Unit tests for openneuro_dashboard.check_s3_files (diff computation).""" + +from __future__ import annotations + +from unittest.mock import patch + +from openneuro_dashboard.check_s3_files import compute_context, compute_diff + + +# ------------------------------------------------------------------ +# compute_diff tests +# ------------------------------------------------------------------ + + +@patch("openneuro_dashboard.check_s3_files.format_timestamp", return_value="2026-01-01T00:00:00.000Z") +def test_identical_sets(_ts): + """Identical git and S3 file sets -> status 'ok', no diffs.""" + files = {"a.txt", "b.txt", "c.txt"} + result = compute_diff("ds000001", "1.0.2", "1.0.2", files, files) + + assert result["status"] == "ok" + assert result["added"] == [] + assert result["removed"] == [] + assert result["exportMissing"] is False + + +@patch("openneuro_dashboard.check_s3_files.format_timestamp", return_value="2026-01-01T00:00:00.000Z") +def test_added_files(_ts): + """Files in git but not S3 appear in 'added'.""" + git_files = {"a.txt", "b.txt", "new.txt"} + s3_files = {"a.txt", "b.txt"} + result = compute_diff("ds000001", "1.0.2", "1.0.2", git_files, s3_files) + + assert result["status"] == "error" + assert "new.txt" in result["added"] + assert result["removed"] == [] + + +@patch("openneuro_dashboard.check_s3_files.format_timestamp", return_value="2026-01-01T00:00:00.000Z") +def test_removed_files(_ts): + """Files in S3 but not git appear in 'removed'.""" + git_files = {"a.txt"} + s3_files = {"a.txt", "old.txt"} + result = compute_diff("ds000001", "1.0.2", "1.0.2", git_files, s3_files) + + assert result["status"] == "error" + assert "old.txt" in result["removed"] + assert result["added"] == [] + + +@patch("openneuro_dashboard.check_s3_files.format_timestamp", return_value="2026-01-01T00:00:00.000Z") +def test_export_missing(_ts): + """S3 has zero files -> exportMissing=True.""" + git_files = {"a.txt", "b.txt"} + s3_files: set[str] = set() + result = compute_diff("ds000001", "1.0.2", "1.0.2", git_files, s3_files) + + assert result["exportMissing"] is True + assert result["status"] == "error" + assert sorted(result["added"]) == ["a.txt", "b.txt"] + + +@patch("openneuro_dashboard.check_s3_files.format_timestamp", return_value="2026-01-01T00:00:00.000Z") +def test_both_empty(_ts): + """Both sets empty -> status 'ok'.""" + result = compute_diff("ds000001", "1.0.2", "1.0.2", set(), set()) + + assert result["status"] == "ok" + assert result["added"] == [] + assert result["removed"] == [] + # exportMissing is True because len(s3_files)==0 + assert result["exportMissing"] is True + + +# ------------------------------------------------------------------ +# compute_context tests +# ------------------------------------------------------------------ + + +def test_context_neighbours(): + """Changed files have +/-3 neighbours included as context.""" + sorted_files = [f"file_{i:02d}.txt" for i in range(10)] + changed = {sorted_files[5]} # file_05.txt + + context = compute_context(sorted_files, changed, radius=3) + + # Neighbours at indices 2,3,4 and 6,7,8 (not 5 itself) + expected = [sorted_files[i] for i in (2, 3, 4, 6, 7, 8)] + assert context == expected + + +def test_context_edge(): + """Changed file at index 0 only has right neighbours.""" + sorted_files = ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"] + changed = {"a.txt"} + + context = compute_context(sorted_files, changed, radius=3) + + assert "a.txt" not in context + assert context == ["b.txt", "c.txt", "d.txt"] + + +def test_context_no_changes(): + """No changed files -> empty context.""" + sorted_files = ["a.txt", "b.txt", "c.txt"] + context = compute_context(sorted_files, set(), radius=3) + assert context == [] diff --git a/code/tests/unit/test_check_s3_version.py b/code/tests/unit/test_check_s3_version.py new file mode 100644 index 0000000000..1ef6931a57 --- /dev/null +++ b/code/tests/unit/test_check_s3_version.py @@ -0,0 +1,142 @@ +"""Unit tests for openneuro_dashboard.check_s3_version (DOI parsing).""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from openneuro_dashboard.check_s3_version import fetch_dataset_description + + +def _mock_response(status_code: int, json_data: dict | None = None, text: str = ""): + """Build a mock httpx.Response with the given status and body.""" + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + + if json_data is not None: + resp.json.return_value = json_data + elif text: + resp.json.side_effect = json.JSONDecodeError("bad", text, 0) + else: + resp.json.return_value = {} + + if status_code >= 400: + exc = httpx.HTTPStatusError( + message=f"HTTP {status_code}", + request=MagicMock(), + response=resp, + ) + resp.raise_for_status.side_effect = exc + else: + resp.raise_for_status.return_value = None + + return resp + + +def _mock_client(response): + """Return an AsyncMock client whose .get() resolves to *response*.""" + client = AsyncMock(spec=httpx.AsyncClient) + client.get.return_value = response + return client + + +# ------------------------------------------------------------------ +# Test cases +# ------------------------------------------------------------------ + + +@patch("openneuro_dashboard.check_s3_version.format_timestamp", return_value="2026-01-01T00:00:00.000Z") +async def test_normal_doi(_ts): + """DOI matching the standard pattern yields version from DOI.""" + body = {"DatasetDOI": "10.18112/openneuro.ds000001.v1.0.2"} + client = _mock_client(_mock_response(200, json_data=body)) + + result = await fetch_dataset_description(client, "ds000001", "1.0.2") + + assert result["extractedVersion"] == "1.0.2" + assert result["versionSource"] == "doi" + assert result["accessible"] is True + assert result["datasetDescriptionDOI"] == body["DatasetDOI"] + + +@patch("openneuro_dashboard.check_s3_version.format_timestamp", return_value="2026-01-01T00:00:00.000Z") +async def test_missing_doi(_ts): + """No DatasetDOI field falls back to assumed_latest.""" + body = {"Name": "Some Dataset"} + client = _mock_client(_mock_response(200, json_data=body)) + + result = await fetch_dataset_description(client, "ds000001", "1.0.2") + + assert result["extractedVersion"] == "1.0.2" + assert result["versionSource"] == "assumed_latest" + assert result["datasetDescriptionDOI"] is None + + +@patch("openneuro_dashboard.check_s3_version.format_timestamp", return_value="2026-01-01T00:00:00.000Z") +async def test_custom_doi(_ts): + """Non-standard DOI that doesn't match the OpenNeuro pattern.""" + body = {"DatasetDOI": "10.5281/zenodo.1234567"} + client = _mock_client(_mock_response(200, json_data=body)) + + result = await fetch_dataset_description(client, "ds000001", "1.0.2") + + assert result["extractedVersion"] == "1.0.2" + assert result["versionSource"] == "assumed_latest" + assert result["datasetDescriptionDOI"] == body["DatasetDOI"] + + +@patch("openneuro_dashboard.check_s3_version.format_timestamp", return_value="2026-01-01T00:00:00.000Z") +async def test_doi_id_mismatch(_ts): + """DOI references a different dataset ID.""" + body = {"DatasetDOI": "10.18112/openneuro.ds999999.v2.0.0"} + client = _mock_client(_mock_response(200, json_data=body)) + + result = await fetch_dataset_description(client, "ds000001", "1.0.2") + + assert result["doiIdMismatch"] is True + assert result["extractedVersion"] == "2.0.0" + assert result["versionSource"] == "doi" + assert result["doiDatasetId"] == "ds999999" + + +@patch("openneuro_dashboard.check_s3_version.format_timestamp", return_value="2026-01-01T00:00:00.000Z") +async def test_403_blocked(_ts): + """HTTP 403 marks the dataset as inaccessible.""" + client = _mock_client(_mock_response(403)) + + result = await fetch_dataset_description(client, "ds000001", "1.0.2") + + assert result["accessible"] is False + assert result["httpStatus"] == 403 + + +@patch("openneuro_dashboard.check_s3_version.format_timestamp", return_value="2026-01-01T00:00:00.000Z") +async def test_404_not_found(_ts): + """HTTP 404 sets datasetDescriptionMissing and falls back to latest.""" + client = _mock_client(_mock_response(404)) + + result = await fetch_dataset_description(client, "ds000001", "1.0.2") + + assert result["datasetDescriptionMissing"] is True + assert result["extractedVersion"] == "1.0.2" + assert result["versionSource"] == "assumed_latest" + assert result["accessible"] is True + + +@patch("openneuro_dashboard.check_s3_version.format_timestamp", return_value="2026-01-01T00:00:00.000Z") +async def test_malformed_json(_ts): + """Invalid JSON body sets invalidJson flag and falls back to latest.""" + resp = _mock_response(200, text="not json") + # Override: make raise_for_status succeed, but json() raise + resp.raise_for_status.side_effect = None + resp.json.side_effect = json.JSONDecodeError("bad json", "", 0) + client = _mock_client(resp) + + result = await fetch_dataset_description(client, "ds000001", "1.0.2") + + assert result["invalidJson"] is True + assert result["extractedVersion"] == "1.0.2" + assert result["versionSource"] == "assumed_latest" diff --git a/code/tests/unit/test_summarize.py b/code/tests/unit/test_summarize.py new file mode 100644 index 0000000000..855922c1a0 --- /dev/null +++ b/code/tests/unit/test_summarize.py @@ -0,0 +1,142 @@ +"""Unit tests for openneuro_dashboard.summarize (status aggregation).""" + +from __future__ import annotations + +from openneuro_dashboard.summarize import summarize_dataset +from openneuro_dashboard.utils import write_json + + +def _write_fixtures(dataset_dir, github=None, s3_version=None, s3_diff=None): + """Write JSON fixture files into *dataset_dir*.""" + if github is not None: + write_json(dataset_dir / "github.json", github) + if s3_version is not None: + write_json(dataset_dir / "s3-version.json", s3_version) + if s3_diff is not None: + write_json(dataset_dir / "s3-diff.json", s3_diff) + + +GITHUB_OK = { + "lastChecked": "2026-01-01T00:00:00.000Z", + "head": "main", + "branches": {"main": "abc123"}, + "tags": {"1.0.2": "abc123"}, +} + +S3_VERSION_DOI_OK = { + "lastChecked": "2026-01-01T00:00:00.000Z", + "accessible": True, + "datasetDescriptionDOI": "10.18112/openneuro.ds000001.v1.0.2", + "extractedVersion": "1.0.2", + "versionSource": "doi", +} + +S3_DIFF_OK = { + "checkedAt": "2026-01-01T00:00:00.000Z", + "status": "ok", + "added": [], + "removed": [], +} + + +# ------------------------------------------------------------------ + + +def test_all_ok(tmp_path): + """All checks pass -> overall status 'ok'.""" + dd = tmp_path / "datasets" / "ds000001" + _write_fixtures(dd, github=GITHUB_OK, s3_version=S3_VERSION_DOI_OK, s3_diff=S3_DIFF_OK) + + result = summarize_dataset("ds000001", "1.0.2", dd) + + assert result["status"] == "ok" + assert result["checks"]["github"] == "ok" + assert result["checks"]["s3Version"] == "ok" + assert result["checks"]["s3Files"] == "ok" + + +def test_github_error(tmp_path): + """Latest tag missing from GitHub -> github 'error', overall 'error'.""" + dd = tmp_path / "datasets" / "ds000001" + github_missing_tag = { + "lastChecked": "2026-01-01T00:00:00.000Z", + "head": "main", + "branches": {"main": "abc123"}, + "tags": {}, + } + _write_fixtures(dd, github=github_missing_tag, s3_version=S3_VERSION_DOI_OK, s3_diff=S3_DIFF_OK) + + result = summarize_dataset("ds000001", "1.0.2", dd) + + assert result["checks"]["github"] == "error" + assert result["status"] == "error" + + +def test_s3_blocked_403(tmp_path): + """S3 inaccessible (403) -> s3Blocked=True, overall 'error'.""" + dd = tmp_path / "datasets" / "ds000001" + s3_blocked = { + "lastChecked": "2026-01-01T00:00:00.000Z", + "accessible": False, + "httpStatus": 403, + } + _write_fixtures(dd, github=GITHUB_OK, s3_version=s3_blocked) + + result = summarize_dataset("ds000001", "1.0.2", dd) + + assert result["s3Blocked"] is True + assert result["checks"]["s3Version"] == "error" + assert result["checks"]["s3Files"] == "error" + assert result["status"] == "error" + + +def test_version_mismatch(tmp_path): + """DOI version != latest -> s3Version 'version-mismatch'.""" + dd = tmp_path / "datasets" / "ds000001" + s3_old = { + "lastChecked": "2026-01-01T00:00:00.000Z", + "accessible": True, + "extractedVersion": "1.0.1", + "versionSource": "doi", + } + _write_fixtures(dd, github=GITHUB_OK, s3_version=s3_old, s3_diff=S3_DIFF_OK) + + result = summarize_dataset("ds000001", "1.0.2", dd) + + assert result["checks"]["s3Version"] == "version-mismatch" + assert result["status"] == "version-mismatch" + + +def test_pending_missing_files(tmp_path): + """Missing check files -> those checks are 'pending'.""" + dd = tmp_path / "datasets" / "ds000001" + dd.mkdir(parents=True, exist_ok=True) + + result = summarize_dataset("ds000001", "1.0.2", dd) + + assert result["checks"]["github"] == "pending" + assert result["checks"]["s3Version"] == "pending" + assert result["checks"]["s3Files"] == "pending" + assert result["status"] == "pending" + + +def test_precedence(tmp_path): + """ok < warning < version-mismatch < error < pending.""" + dd = tmp_path / "datasets" / "ds000001" + # github ok, s3Version version-mismatch, s3Files pending (no s3-diff) + s3_old = { + "lastChecked": "2026-01-01T00:00:00.000Z", + "accessible": True, + "extractedVersion": "1.0.1", + "versionSource": "doi", + } + _write_fixtures(dd, github=GITHUB_OK, s3_version=s3_old) + # s3-diff absent -> s3Files pending + + result = summarize_dataset("ds000001", "1.0.2", dd) + + assert result["checks"]["github"] == "ok" + assert result["checks"]["s3Version"] == "version-mismatch" + assert result["checks"]["s3Files"] == "pending" + # pending > version-mismatch > ok + assert result["status"] == "pending" diff --git a/code/tests/unit/test_utils.py b/code/tests/unit/test_utils.py new file mode 100644 index 0000000000..4e89e8819d --- /dev/null +++ b/code/tests/unit/test_utils.py @@ -0,0 +1,73 @@ +"""Unit tests for openneuro_dashboard.utils.""" + +from __future__ import annotations + +import json +import re + +from openneuro_dashboard.utils import ( + SCHEMA_VERSION, + format_timestamp, + load_json, + load_json_safe, + write_json, +) + + +def test_write_json_load_json_roundtrip(tmp_path): + """write_json then load_json returns the same dict.""" + data = {"key": "value", "nested": {"a": 1}} + path = tmp_path / "test.json" + write_json(path, data) + + result = load_json(path) + assert result == data + + +def test_write_json_creates_directories(tmp_path): + """write_json creates intermediate parent directories.""" + path = tmp_path / "deep" / "nested" / "dir" / "file.json" + write_json(path, {"x": 1}) + + assert path.exists() + assert load_json(path) == {"x": 1} + + +def test_write_json_format(tmp_path): + """Output uses indent=2 and ends with a trailing newline.""" + data = {"a": 1} + path = tmp_path / "fmt.json" + write_json(path, data) + + raw = path.read_text() + assert raw.endswith("\n") + # Verify indent by checking the structure matches json.dumps(indent=2) + expected = json.dumps(data, indent=2) + "\n" + assert raw == expected + + +def test_load_json_safe_missing(tmp_path): + """load_json_safe returns None for a missing file.""" + result = load_json_safe(tmp_path / "nonexistent.json") + assert result is None + + +def test_load_json_safe_existing(tmp_path): + """load_json_safe returns dict for an existing file.""" + path = tmp_path / "existing.json" + write_json(path, {"hello": "world"}) + + result = load_json_safe(path) + assert result == {"hello": "world"} + + +def test_format_timestamp(): + """format_timestamp returns ISO 8601 matching YYYY-MM-DDTHH:MM:SS.000Z.""" + ts = format_timestamp() + pattern = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$" + assert re.match(pattern, ts), f"Timestamp {ts!r} does not match expected pattern" + + +def test_schema_version(): + """SCHEMA_VERSION is '1.1.0'.""" + assert SCHEMA_VERSION == "1.1.0" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000..b2562e69c1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "openneuro-dashboard" +version = "0.1.0" +description = "Data-population pipeline for the OpenNeuro dashboard." +authors = [ + { name = "Christopher J. Markiewicz", email = "markiewicz@stanford.edu" } +] +license = "Apache-2.0" +requires-python = ">=3.14" +dependencies = [ + "aioboto3>=15.5.0", + "cattrs>=25.3.0", + "gql[httpx]>=4.0.0", + "httpx>=0.28.1", + "ondiagnostics @ git+https://github.com/openneuroorg/ondiagnostics", + "pygit2>=1.19.1", + "rich>=14.3.2", + "stamina>=25.2.0", + "typer>=0.23.1", +] + +[project.scripts] +openneuro-dashboard = "openneuro_dashboard.__main__:app" + +[build-system] +requires = ["uv_build>=0.10.2,<0.11.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-root = "code/src" + +## Use this to work on paired refactors +# [tool.uv.sources] +# ondiagnostics = { path = "../ondiagnostics", editable = true } + +[dependency-groups] +dev = [ + "ipython>=9.10.0", +] +test = [ + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/scripts/fetch_graphql.py b/scripts/fetch_graphql.py deleted file mode 100644 index f1ed61905d..0000000000 --- a/scripts/fetch_graphql.py +++ /dev/null @@ -1,380 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.13" -# dependencies = [ -# "cattrs>=26.1.0", -# "gql[httpx]>=4.0.0", -# "httpx>=0.28.1", -# "stamina>=25.2.0", -# ] -# /// -""" -Fetch dataset information from OpenNeuro GraphQL API. - -Writes: -- data/datasets-registry.json -- data/datasets/{id}/snapshots.json -- data/datasets/{id}/snapshots/{tag}/metadata.json -""" - -import argparse -import asyncio -import json -from pathlib import Path -from datetime import datetime, UTC -from dataclasses import dataclass - -import cattrs -import httpx -import stamina -import gql -from gql.transport.httpx import HTTPXAsyncTransport -from gql.transport.exceptions import TransportQueryError - -from utils import SCHEMA_VERSION, format_timestamp - -ENDPOINT = "https://openneuro.org/crn/graphql" - -converter = cattrs.Converter() - - -# GraphQL data structures -@dataclass -class Snapshot: - """Snapshot metadata from GraphQL.""" - - tag: str - hexsha: str - created: str - - -@dataclass -class DatasetNode: - """Dataset node from GraphQL response.""" - - id: str - latestSnapshot: Snapshot - snapshots: list[Snapshot] - - -@dataclass -class DatasetEdge: - """Edge wrapper for dataset node.""" - - node: DatasetNode | None - - -@dataclass -class PageInfo: - """Pagination information.""" - - hasNextPage: bool = True - endCursor: str | None = None - count: int = 0 - - -@dataclass -class DatasetsResponse: - """Response containing datasets and pagination info.""" - - edges: list[DatasetEdge | None] - pageInfo: PageInfo - - -@dataclass -class GraphQLResponse: - """Top-level GraphQL response.""" - - datasets: DatasetsResponse - - -GET_DATASETS = gql.gql(""" -query DatasetsWithSnapshots($count: Int, $after: String) { - datasets( - first: $count, - after: $after, - orderBy: {created: ascending} - filterBy: {public: true} - ) { - edges { - node { - id - latestSnapshot { - tag - hexsha - created - } - snapshots { - tag - hexsha - created - } - } - } - pageInfo { - hasNextPage - endCursor - count - } - } -} -""") - - -@stamina.retry(on=httpx.HTTPError) -async def get_page( - client: gql.Client, count: int, after: str | None -) -> GraphQLResponse: - """Fetch a page of datasets from the GraphQL API.""" - try: - result = await client.execute( - GET_DATASETS, variable_values={"count": count, "after": after} - ) - except TransportQueryError as e: - if e.data is not None: - result = e.data - else: - raise e - return converter.structure(result, GraphQLResponse) - - -async def _fetch_pages( - client: gql.Client, queue: asyncio.Queue, page_size: int, verbose: bool -) -> None: - """ - Fetch pages and put them in queue for processing. - - Args: - client: GraphQL client - queue: Queue to put pages into - page_size: Number of datasets per page - verbose: Enable verbose logging - """ - page_info = PageInfo() - page_num = 0 - - try: - while page_info.hasNextPage: - result = await get_page(client, page_size, page_info.endCursor) - - page_info = result.datasets.pageInfo - page_num += 1 - - if verbose: - print( - f" Fetched page {page_num}: {len(result.datasets.edges)} datasets, " - f"hasNext={page_info.hasNextPage}" - ) - - await queue.put(result.datasets.edges) - finally: - # Signal completion - await queue.put(None) - if verbose: - print(f" Page fetching complete: {page_num} pages") - - -def write_json(path: Path, data: dict, dry_run: bool) -> None: - """Write JSON to file with pretty formatting.""" - if dry_run: - print(f" [dry-run] Would write {path}") - return - - path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w") as f: - json.dump(data, f, indent=2) - f.write("\n") - - -async def fetch_and_write( - output_dir: Path, page_size: int, prefetch: int, dry_run: bool, verbose: bool -) -> None: - """ - Fetch from GraphQL and write files. - - Args: - output_dir: Directory to write data files - page_size: Number of datasets per GraphQL page - prefetch: Number of pages to prefetch - dry_run: If True, don't write files - verbose: If True, enable verbose logging - """ - transport = HTTPXAsyncTransport(url=ENDPOINT) - async with gql.Client(transport=transport) as client: - # Get total count - first_page = await get_page(client, 0, None) - total_count = first_page.datasets.pageInfo.count - print(f"Starting fetch: {total_count} total datasets") - - # Setup queue and background fetcher - queue: asyncio.Queue = asyncio.Queue(maxsize=prefetch) - fetch_task = asyncio.create_task( - _fetch_pages(client, queue, page_size, verbose) - ) - - # Track progress - latest_snapshots = {} - processed = 0 - timestamp = format_timestamp() - - try: - # Process datasets as they arrive - while True: - edges = await queue.get() - - # None signals end of pages - if edges is None: - break - - for edge in edges: - if edge is None: - continue - if edge.node is None: - print("⚠ Null node in edge") - continue - - dataset = edge.node - - # Validate snapshots exist - if not dataset.snapshots: - print(f"⚠ Dataset {dataset.id} has no snapshots, skipping") - continue - - processed += 1 - - # Track latest snapshot for registry - latest_snapshots[dataset.id] = dataset.latestSnapshot.tag - - # Write snapshots.json - dataset_dir = output_dir / "datasets" / dataset.id - snapshots_index = { - "schemaVersion": SCHEMA_VERSION, - "tags": [snap.tag for snap in dataset.snapshots], - } - write_json(dataset_dir / "snapshots.json", snapshots_index, dry_run) - - # Write metadata.json for each snapshot - for snapshot in dataset.snapshots: - snapshot_dir = dataset_dir / "snapshots" / snapshot.tag - metadata = { - "schemaVersion": SCHEMA_VERSION, - "hexsha": snapshot.hexsha, - "created": snapshot.created, - } - write_json(snapshot_dir / "metadata.json", metadata, dry_run) - - # Progress logging every 100 datasets - if processed % 100 == 0: - percent = 100 * processed / total_count - print(f"Progress: {processed}/{total_count} ({percent:.1f}%)") - - finally: - # Ensure fetch task completes - await fetch_task - - # Write registry - registry = { - "schemaVersion": SCHEMA_VERSION, - "lastChecked": timestamp, - "totalCount": len(latest_snapshots), - "latestSnapshots": latest_snapshots, - } - - registry_path = output_dir / "datasets-registry.json" - write_json(registry_path, registry, dry_run) - - print(f"\n✓ Fetch complete: {processed} datasets processed") - if not dry_run: - print(f" Registry written to: {registry_path}") - - -def validate_output(output_dir: Path) -> None: - """Validate the output data for consistency.""" - print("\nValidating output...") - - registry_path = output_dir / "datasets-registry.json" - if not registry_path.exists(): - print(" ✗ datasets-registry.json not found") - return - - import json - with open(registry_path) as f: - registry = json.load(f) - - issues = [] - for dataset_id, latest_snapshot in registry["latestSnapshots"].items(): - dataset_dir = output_dir / "datasets" / dataset_id - - snapshots_path = dataset_dir / "snapshots.json" - if not snapshots_path.exists(): - issues.append(f"{dataset_id}: snapshots.json missing") - continue - - with open(snapshots_path) as f: - snapshots = json.load(f) - - if latest_snapshot not in snapshots["tags"]: - issues.append(f"{dataset_id}: latest {latest_snapshot} not in tags") - - for tag in snapshots["tags"]: - metadata_path = dataset_dir / "snapshots" / tag / "metadata.json" - if not metadata_path.exists(): - issues.append(f"{dataset_id}: metadata.json missing for {tag}") - - if issues: - print(f"\n Issues found ({len(issues)}):") - for issue in issues[:20]: - print(f" {issue}") - if len(issues) > 20: - print(f" ... and {len(issues) - 20} more") - else: - print(" ✓ No issues found") - - -def main(): - parser = argparse.ArgumentParser( - description="Fetch dataset info from OpenNeuro GraphQL API" - ) - parser.add_argument( - "--output-dir", - type=Path, - default=Path("data"), - help="Output directory for data files (default: data)", - ) - parser.add_argument( - "--page-size", - type=int, - default=100, - help="Number of datasets per GraphQL page (default: 100)", - ) - parser.add_argument( - "--prefetch", - type=int, - default=4, - help="Number of pages to prefetch (default: 2)", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Don't write files, just log what would be done", - ) - parser.add_argument("--verbose", action="store_true", help="Enable verbose logging") - parser.add_argument( - "--validate", action="store_true", - help="Validate output data after fetching", - ) - - args = parser.parse_args() - - asyncio.run( - fetch_and_write( - args.output_dir, args.page_size, args.prefetch, args.dry_run, args.verbose - ) - ) - - if args.validate: - validate_output(args.output_dir) - - -if __name__ == "__main__": - main() diff --git a/scripts/utils.py b/scripts/utils.py deleted file mode 100644 index 1e681f250d..0000000000 --- a/scripts/utils.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Shared utilities for test data generation.""" - -import json -import random -from pathlib import Path -from datetime import datetime, timedelta, UTC - -SCHEMA_VERSION = "1.1.0" - - -def format_timestamp() -> str: - """Format current UTC time in the standard dashboard timestamp format.""" - return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z") - - -def random_sha() -> str: - """Generate a random git SHA.""" - return "".join(random.choices("0123456789abcdef", k=40)) - - -def random_datetime(days_ago: int = 30) -> str: - """Generate a random datetime within the last N days.""" - dt = datetime.now(UTC) - timedelta(days=random.randint(0, days_ago)) - return dt.strftime("%Y-%m-%dT%H:%M:%S.000Z") - - -def generate_dataset_id(num: int) -> str: - """Generate a dataset ID.""" - return f"ds{num:06d}" - - -def generate_version_tag(major: int = 1, minor: int = 0) -> str: - """Generate a semantic version tag.""" - return f"{major}.{minor}.{random.randint(0, 5)}" - - -def generate_old_style_tag() -> str: - """Generate an old-style numeric tag starting with 0.""" - return f"0{random.randint(1000, 9999):04d}" - - -def get_latest_snapshot(tags: list[str]) -> str: - """Get the latest snapshot from a list of tags.""" - return max(tags) - - -def generate_snapshots(num_snapshots: int = None) -> list[str]: - """Generate a list of snapshot tags in chronological order.""" - if num_snapshots is None: - num_snapshots = random.randint(1, 7) - - tags = [] - # Sometimes include old-style tags - if random.random() < 0.3: - for _ in range(random.randint(1, 3)): - tags.append(generate_old_style_tag()) - - # Add semantic version tags - for i in range(num_snapshots): - tags.append(generate_version_tag(minor=i)) - - # Sort: old-style tags first, then semantic versions - old_style = sorted([t for t in tags if t[0] == "0" and "." not in t]) - semantic = sorted( - [t for t in tags if "." in t], key=lambda x: tuple(map(int, x.split("."))) - ) - - return old_style + semantic - - -def generate_file_paths(size: str = "medium") -> list[str]: - """Generate a realistic list of file paths.""" - sizes = {"small": 50, "medium": 500, "large": 5000, "xlarge": 20000} - - num_files = sizes.get(size, 500) - files = [ - "dataset_description.json", - "participants.tsv", - "participants.json", - "README", - "CHANGES", - ] - - num_subjects = random.randint(5, 50) - for sub in range(1, num_subjects + 1): - sub_id = f"sub-{sub:02d}" - - # Anatomical - if random.random() < 0.9: - files.append(f"{sub_id}/anat/{sub_id}_T1w.nii.gz") - files.append(f"{sub_id}/anat/{sub_id}_T1w.json") - - # Functional - num_runs = random.randint(1, 4) - for run in range(1, num_runs + 1): - if random.random() < 0.8: - files.append( - f"{sub_id}/func/{sub_id}_task-rest_run-{run:02d}_bold.nii.gz" - ) - files.append( - f"{sub_id}/func/{sub_id}_task-rest_run-{run:02d}_bold.json" - ) - - # Pad to approximate size - while len(files) < num_files: - sub_id = f"sub-{random.randint(1, num_subjects):02d}" - session = random.randint(1, 3) - files.append( - f"{sub_id}/ses-{session:02d}/anat/{sub_id}_ses-{session:02d}_T2w.nii.gz" - ) - - return sorted(set(files))[:num_files] - - -def write_json(path: Path, data: dict) -> None: - """Write JSON to file with pretty formatting.""" - path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w") as f: - json.dump(data, f, indent=2) - f.write("\n") - - -def load_json(path: Path) -> dict: - """Load JSON from file.""" - with open(path) as f: - return json.load(f) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..8c9f8f236e --- /dev/null +++ b/uv.lock @@ -0,0 +1,1057 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "aioboto3" +version = "15.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiobotocore", extra = ["boto3"] }, + { name = "aiofiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069, upload-time = "2025-10-30T13:37:16.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913, upload-time = "2025-10-30T13:37:14.549Z" }, +] + +[[package]] +name = "aiobotocore" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/94/2e4ec48cf1abb89971cb2612d86f979a6240520f0a659b53a43116d344dc/aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc", size = 120560, upload-time = "2025-10-28T22:33:21.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039, upload-time = "2025-10-28T22:33:19.949Z" }, +] + +[package.optional-dependencies] +boto3 = [ + { name = "boto3" }, +] + +[[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 = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aioitertools" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[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 = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +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 = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "boto3" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535, upload-time = "2025-10-28T19:26:57.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321, upload-time = "2025-10-28T19:26:55.007Z" }, +] + +[[package]] +name = "botocore" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956, upload-time = "2025-10-28T19:26:46.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload-time = "2025-10-28T19:26:42.15Z" }, +] + +[[package]] +name = "cattrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[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 = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "gql" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "backoff" }, + { name = "graphql-core" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/9f/cf224a88ed71eb223b7aa0b9ff0aa10d7ecc9a4acdca2279eb046c26d5dc/gql-4.0.0.tar.gz", hash = "sha256:f22980844eb6a7c0266ffc70f111b9c7e7c7c13da38c3b439afc7eab3d7c9c8e", size = 215644, upload-time = "2025-08-17T14:32:35.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/94/30bbd09e8d45339fa77a48f5778d74d47e9242c11b3cd1093b3d994770a5/gql-4.0.0-py3-none-any.whl", hash = "sha256:f3beed7c531218eb24d97cb7df031b4a84fdb462f4a2beb86e2633d395937479", size = 89900, upload-time = "2025-08-17T14:32:34.029Z" }, +] + +[package.optional-dependencies] +httpx = [ + { name = "httpx" }, +] + +[[package]] +name = "graphql-core" +version = "3.2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/c5/36aa96205c3ecbb3d34c7c24189e4553c7ca2ebc7e1dd07432339b980272/graphql_core-3.2.8.tar.gz", hash = "sha256:015457da5d996c924ddf57a43f4e959b0b94fb695b85ed4c29446e508ed65cf3", size = 513181, upload-time = "2026-03-05T19:55:37.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/41/cb887d9afc5dabd78feefe6ccbaf83ff423c206a7a1b7aeeac05120b2125/graphql_core-3.2.8-py3-none-any.whl", hash = "sha256:cbee07bee1b3ed5e531723685369039f32ff815ef60166686e0162f540f1520c", size = 207349, upload-time = "2026-03-05T19:55:35.911Z" }, +] + +[[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 = "ipython" +version = "9.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[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]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, +] + +[[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 = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "ondiagnostics" +version = "0.1.0" +source = { git = "https://github.com/openneuroorg/ondiagnostics#68cf92e077dc9d31112d0e75e445f81f47b0cf9d" } +dependencies = [ + { name = "aioboto3" }, + { name = "cattrs" }, + { name = "gql", extra = ["httpx"] }, + { name = "pygit2" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stamina" }, + { name = "structlog" }, + { name = "typer" }, +] + +[[package]] +name = "openneuro-dashboard" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aioboto3" }, + { name = "cattrs" }, + { name = "gql", extra = ["httpx"] }, + { name = "httpx" }, + { name = "ondiagnostics" }, + { name = "pygit2" }, + { name = "rich" }, + { name = "stamina" }, + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ipython" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "aioboto3", specifier = ">=15.5.0" }, + { name = "cattrs", specifier = ">=25.3.0" }, + { name = "gql", extras = ["httpx"], specifier = ">=4.0.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "ondiagnostics", git = "https://github.com/openneuroorg/ondiagnostics" }, + { name = "pygit2", specifier = ">=1.19.1" }, + { name = "rich", specifier = ">=14.3.2" }, + { name = "stamina", specifier = ">=25.2.0" }, + { name = "typer", specifier = ">=0.23.1" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "ipython", specifier = ">=9.10.0" }] +test = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, +] + +[[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 = "parso" +version = "0.8.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[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 = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygit2" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/49/cf8350817de19f4cafe4ae47881e38f56d9bbebaa9e5ef31a5458af4bcf8/pygit2-1.19.1.tar.gz", hash = "sha256:3165f784aae56a309a27d8eeae7923d53da2e8f6094308c7f5b428deec925cf9", size = 800869, upload-time = "2025-12-29T11:47:48.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/45/1284c7714070b51e3413e66b677fa4ecf8c840d2f86d1bebc77d2390fe3d/pygit2-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d10a46285b9ae39b9de2d9f44ac7f933993aecfab189c2932320b3df596311c8", size = 5702338, upload-time = "2025-12-29T11:47:12.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/7a39d4c612e12966130504e1610f500b397d7968feb6d25e1353614dab74/pygit2-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d0f3924d8d0d54a7fe186761c76dc1b6e5fcf41794a6daba1630db3bc216b9ba", size = 5692261, upload-time = "2025-12-29T11:47:14.276Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/7806cf0ae9200bd773628be6d8c345d277b8f0161de950b572a4ce200105/pygit2-1.19.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4fcc301cfe9c29f3e29f0f80d81ae65c0bee368672b23566467dc91b5edae4b", size = 6025106, upload-time = "2025-12-29T11:47:15.904Z" }, + { url = "https://files.pythonhosted.org/packages/dc/30/7f1b67711705eb0220dcc4581a97b4aebad4ffde2f6f6b94314690e1cfa1/pygit2-1.19.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6eacf82f15e001121dc0f60057f462627045447d8bd8587b33b13159ae5155", size = 4627355, upload-time = "2025-12-29T11:47:17.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/25f1e65ff6ed678e1be9aaeabeedcb26531d17b6b86c4b1d50d8f0c50825/pygit2-1.19.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:074b0b14c6f3c7e2c6ea0b01a90832407a71520c920918aa07f509c91f1691f9", size = 5788548, upload-time = "2025-12-29T11:47:18.98Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5d/ff1b12d3682918ac6c3c6629a6c6272db1b4041994d38045d3c334034570/pygit2-1.19.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ada5d3e813e21918e004a33c66aba4a2b829cd5c0c0e85b92dd70f84cf95ac56", size = 6030078, upload-time = "2025-12-29T11:47:20.324Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c5/c078ed6f1f5d7f3feba4b86d53e464c8358112ec32943e11e36557009818/pygit2-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:19ebe25fd8e95ed8a0be0a9dd4cecc1233db4f2a44a2a73984620909e98e907f", size = 5757154, upload-time = "2025-12-29T11:47:21.971Z" }, + { url = "https://files.pythonhosted.org/packages/76/90/1722d7c2db5d563becb59a54b2f49b44964ff699826629f96594064d972a/pygit2-1.19.1-cp314-cp314-win32.whl", hash = "sha256:5bc0738a49cceb76f0fba7cdb24532857a980e4a36b9a0da025c359dfe3676b4", size = 964159, upload-time = "2025-12-29T11:47:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/74/72/80558b71ed780a732c9ff10003c3a73b68fbf320c3125ae11bb664a8076c/pygit2-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:527d40925bb85b86da0e96ecc90e9ca74d0a0273ab645bac0787b95923d93160", size = 1190612, upload-time = "2025-12-29T11:47:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/28/68/c60ff9ae38543a520ca93c0d52a52c2e375ac44b9a5c5da99044cca8c5c5/pygit2-1.19.1-cp314-cp314-win_arm64.whl", hash = "sha256:21c7c8b5aa2f48cefdb8521185f0cd3c110a340e2d9f62a46a94db01a907db73", size = 994766, upload-time = "2025-12-29T11:47:25.902Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/4da546bf55183877e7da4327594ab138db92aa00921d46d513626bcad19f/pygit2-1.19.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9c5e4eb975b664b6821fe6a05b03bbc51052d1fb22f20652e1d4349ae24ed7ac", size = 5705642, upload-time = "2025-12-29T11:47:27.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b7/74a9cf3d2e6cd6bd2fa6a7bc3530054c2f720fc59e3b731251bbdebd8983/pygit2-1.19.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8752eae5780ee51edae326cac394868917704624b63d03a5217c5e94a532a0e3", size = 5695192, upload-time = "2025-12-29T11:47:28.98Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b9/bde02249c2c5deecc8e483ee9132f86f67114eec154ee10219d23a1ce9f9/pygit2-1.19.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:457f5a2e6d8527b5ad7a8bd16586c72ad2ce0aa218a37380f16d07520569ceaf", size = 6085318, upload-time = "2025-12-29T11:47:30.268Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/b3a14edaa579700aee33a25a788f5f4fe67713a6e2273a897635e6742b35/pygit2-1.19.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c8a9d53c84724c97d7e298f6628655c19f9911a90b88c362cb7d5daa645464f", size = 4684691, upload-time = "2025-12-29T11:47:31.829Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/5f557be149931ef7d692b66296a44263a1769070466eb1e63d6d1b3b97c1/pygit2-1.19.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d8442ad863be83be86baff006a6e11de3cddf17c7ee77eac2d389765987b554", size = 5841500, upload-time = "2025-12-29T11:47:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f7/0101b3058e64df334c48193dfd6f1493a24b0c7813382c6b2e4db7a09ffa/pygit2-1.19.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ae9c775be518c7f20bf340091d329d3b9203cbd4273bf1b5505dc82dccf08147", size = 6087805, upload-time = "2025-12-29T11:47:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/26/7d3fa88362b1703cd5b9bde411f37cded3b1f99dc83b720fc0c65ac8f37b/pygit2-1.19.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d5a45d466a4bc5d9eb0619ffc26b17e4018285e35ba9e2fe39576f13480b63bc", size = 5809156, upload-time = "2025-12-29T11:47:36.396Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/f1952af3f61b3a7a49c417ffb67a5140c1183e6b04ec714c8941937860bf/pygit2-1.19.1-cp314-cp314t-win32.whl", hash = "sha256:6621acaaf2670e8fd0727c15271e5209a99769b127300ef7fc56b49babc8b1c1", size = 969317, upload-time = "2025-12-29T11:47:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/31/02/205a4d10cb1195f6abf0a509883ede90caddefca6d9c3b54ef96e79e8e8a/pygit2-1.19.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4418dea6936fe3c1a9375d7cd31a69e72997e645e588ed31c40d785c71adde35", size = 1197068, upload-time = "2025-12-29T11:47:39.065Z" }, + { url = "https://files.pythonhosted.org/packages/00/20/4571edf9bebc9d60dcf5d5c3cd0a12e55a79b91b02ef960c44e4ffc24c70/pygit2-1.19.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3cbb8ab952224c0b305aa56f8759bcad5d9a9de885b00fe0ff8bed9ac365472e", size = 995635, upload-time = "2025-12-29T11:47:40.327Z" }, +] + +[[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 = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +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 = "pytest" }, +] +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 = "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 = "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/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 = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[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 = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "stamina" +version = "25.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/b7/8064b246b3d684720080ee8ffbf1dde5caabe852eb9cb53655eb97992af2/stamina-25.2.0.tar.gz", hash = "sha256:fdff938789e8a0c4c496e1ee8a08ee3c7c3351239f235b53e60d4f5964d07e19", size = 565737, upload-time = "2025-12-11T09:16:59.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/81/c525760353dff91ae2e4c42c3f3d9bf0bfeecbb6165cc393e86915f1717d/stamina-25.2.0-py3-none-any.whl", hash = "sha256:7f0de7dba735464c256a31e6372c01b8bb51fb6efd649e6773f4ce804462feea", size = 18791, upload-time = "2025-12-11T09:16:57.235Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[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 = "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 = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +]