diff --git a/.gitignore b/.gitignore index 83972fa..2bd7f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Ignore /data/ as a scratch space. +/data/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] @@ -186,7 +189,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9c1b6af --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,231 @@ +# CLAUDE.md + +## What this is + +A dashboard for tracking how well NCATS Translator KGX ingests normalize against +[Babel](https://github.com/NCATSTranslator/Babel)/[NodeNorm](https://github.com/biothings/NodeNormalizationAPI). +The DINGO team transforms biomedical sources into KGX `_nodes.jsonl`/`_edges.jsonl`, normalizes node +CURIEs through NodeNorm, and publishes the results to KGX Storage. This repo consumes the +normalization side of those outputs. + +Three goals, in order: + +1. Summary of all prefixes normalized across every data source in KGX Storage. +2. Report of CURIEs that could not be normalized — this drives Babel ingest prioritization. +3. Renormalize a build's normalization output against a new Babel release to estimate how much the + new release would change the results. + +Current shape: a local [Dash](https://plotly.com/dash/) app over a local mirror of KGX Storage. +Local is deliberate — individual unnormalized CURIEs stay off the public web while we work out what +the dashboard should show, and Dash is the form that can later fold into other Translator +dashboards. Nothing is hosted yet; see the Hosting section of [docs/Loading.md](docs/Loading.md). + +Goal 1 is built. Goals 2 and 3 are partly served by the click-through CURIE listing, and the +normalization maps are mirrored ready for goal 3. + +## KGX Storage + +`https://kgx-storage.ci.transltr.io/` fronts `s3://kgx-translator-ingests`. See `/docs` on that site. + +- **HTTPS is the only public access path.** The bucket is *not* anonymously listable — both + `aws s3 ls --no-sign-request` and the S3 REST API return `AccessDenied`. The site's HTML directory + listings are the sole public index. `rclone`'s `:http:` backend parses them correctly, which is why + the sync script is one `rclone sync` call. +- **File URLs 302-redirect to presigned S3 URLs**, so any client must follow redirects — use + `curl -fL`. Directory URLs need a trailing slash. Appending `?view` to a JSON URL opens an HTML + viewer instead of returning the raw file. +- **Large JSON files 502.** The origin appears to buffer JSON responses in memory: a 48 MB + `normalization_map.json` downloads fine, but ubergraph's 247 MB one + (`data/ubergraph/2026-05-31/transform_e7f773ea/normalization_.../normalization_map.json`) returns + 502 Bad Gateway to every client. This is an upstream bug worth reporting to DINGO; until it's + fixed, a full sync exits non-zero because of that one file. + +### Path layout + +``` +data/{source}/{source_version}/transform_{hash}/normalization_{babel}_{nn}_{code}_{conflated}_{strict}/ + normalization-metadata.json + normalization_failures.txt + normalization_map.json + normalized_nodes.jsonl # huge, never download + normalized_edges.jsonl # huge, never download + merge_{version}/ +data/{source}/latest-build.json # index: which transform/normalization is current +releases/latest-release-summary.json # index: every source's current release +releases/{source}/{release_version}/ +``` + +The normalization directory name encodes Babel version, NodeNorm version, normalization code +version, and the conflation/strict flags. Every directory in the bucket currently uses +`normalization_2025sep1_2.4.1_1.4.0_conflated_strict` — one Babel version so far, but the layout +allows several to coexist and the sync handles that without changes. + +### File semantics + +- `normalization-metadata.json` — pre/post node counts, failure count, edge counts, and + `normalization_by_prefix`: per source prefix `{succeeded, failed, total, success_rate, + normalized_to: {target_prefix: count}}`. This is the input for goal 1. Tiny (~2–7 KB each). + Note its `success_rate` is **truncated, not rounded** (99.556 → 99.55; 128 of 587 rows differ), + so `loader.py` recomputes the rate from the counts instead of reading that field. +- `normalization_failures.txt` — one unnormalized source CURIE per line. Input for goal 2. Not + present in every build (e.g. ctkp, dakp have none). +- `normalization_map.json` — `{"normalization_map": {"": [""] | null}}`. + Failures are the `null` entries, so `normalization_failures.txt` is a subset of this file's keys. + Kept anyway because it's 16× smaller and directly answers goal 2. The map is what goal 3 needs. + +### Node files — labels and Biolink types, not mirrored (yet) + +Two files we deliberately do **not** download, recorded here as sources of label/type information +for when a report needs it. Both are large; check the size before pulling any of them. + +**`normalization_{...}/normalized_nodes.jsonl`** — post-normalization. One JSON object per line with +the preferred CURIE, the full Biolink category ancestor list, the label, the clique members, and an +information content score: + +```json +{"id": "CHEBI:165710", "category": ["biolink:SmallMolecule", "...", "biolink:NamedThing"], + "name": "Palmitoleyl linoleate", + "equivalent_identifiers": ["CHEBI:165710", "PUBCHEM.COMPOUND:56935947", "INCHIKEY:NBQ..."], + "information_content": 100.0, "description": "..."} +``` + +This is what would let a summary report say *what* a normalized node is, not just that it +normalized. Ubergraph's alone is 384 MB; **5.7 GB across all 90 builds**, so mirroring these means +restricting to the latest build per source (~31 files) rather than adding them to the sync filters. + +**`transform_{hash}/{source}_nodes.jsonl`** — the transform output, generated *before* +normalization. This is the one that says what the upstream source knew about a CURIE we could +**not** normalize, which is exactly the missing half of the unnormalized-CURIE report. How much it +knows varies sharply by source, so check before relying on it: + +| source | first line | +|---|---| +| pathbank (305 MB) | `{"id":"SMPDB:SMP0000055","category":["biolink:Pathway"],"name":"Alanine Metabolism","description":"Alanine (L-Alanine) is an α-amino acid…"}` | +| ncbi_gene (74 MB) | `{"id":"NCBIGene:1","category":["biolink:Gene"],"name":"A1BG","description":"alpha-1-B glycoprotein","full_name":…,"taxon":"NCBITaxon:9606","symbol":"A1BG"}` | +| goa (15 MB) | `{"name":"NUDT4B","id":"UniProtKB:A0A024RBG1","category":["biolink:Protein"],"description":"Diphosphoinositol polyphosphate phosphohydrolase NUDT4B","in_taxon":["NCBITaxon:9606"]}` | +| ubergraph (49 MB) | `{"id":"CHEBI:165710","category":["biolink:NamedThing"]}` — **no label, no real type** | + +So for pathbank — currently the worst source, 215,953 unnormalized PathBank/SMPDB CURIEs — the +transform output has names and descriptions and would make a genuinely actionable Babel ingest +report. For ubergraph it would add nothing; those labels live in the ontologies themselves. + +### Scale (full crawl of `data/`, Aug 2026) + +90 normalization directories; a full listing crawl takes ~40s. + +| file | count | total | +|---|---|---| +| `normalization-metadata.json` | 90 | 0.2 MB | +| `normalization_failures.txt` | 87 | 56 MB | +| `normalization_map.json` | 90 | 914 MB | + +### CURIE prefix case + +NodeNorm resolves CURIE prefixes case-insensitively — `ENSEMBL:ENSG00000139618`, `Ensembl:…` and +`ensembl:…` all return `NCBIGene:675`. So anything that summarizes must pool prefixes +case-insensitively (`loader.py` carries `prefix_key = prefix.upper()`), while individual records +keep the case as observed in the files. In the current mirror this merges 86 observed source +prefixes into 84: `Ensembl`/`ENSEMBL` and `SIGNOR`/`signor`. Both collisions happen to be across +different sources, so per-source rows never show two spellings today — the folding matters for +cross-source rollups. + +## The app + +See [docs/Loading.md](docs/Loading.md) for the runbook. Layout: + +- `src/normalization_dashboard/loader.py` — no Dash imports, returns plain `list[dict]` so a + notebook, the Dash app and a future static-JSON exporter can all reuse it. `load_rows()` gives one + row per (build, prefix); `summarize()` pools by (source, case-insensitive prefix); + `load_failure_examples()` collects a few real failing CURIEs per (source, prefix); + `spread()` samples evenly across a list rather than taking its head. +- `src/normalization_dashboard/app.py` — the Dash app. `uv run normalization-dashboard`, or + `PORT=8051` for a second instance. +- `src/normalization_dashboard/curie.py` — CURIE → URL, malformed detection, and the `problem()` / + `stem()` grouping used by the click-through listing. +- `tests/test_loader.py`, `tests/test_curie.py` — invariant checks against the real mirror, no + framework. `uv run python tests/test_loader.py`. The loader one skips cleanly if the mirror is + not synced. + +### Gotchas worth not rediscovering + +- **DataTable cells may only hold a string, number or boolean.** A list or dict on a row makes the + browser reject the *whole* table with `Invalid argument data[0].x passed into DataTable`. This is + a client-side propType check, so no server-side test and no `curl` of the callback endpoint will + ever see it — `test_loader.py` asserts every `summarize()` value is a scalar for exactly this + reason. More generally: verifying Dash callbacks over HTTP proves the data, never the rendering. +- **`active_cell["row"]` indexes the sorted, filtered viewport**, not the `data` prop. Read + `derived_viewport_data`, or a click after re-sorting silently picks the wrong row. +- **Row identity beats row index** for `style_data_conditional`: the selected-row highlight matches + on `{prefix}` and `{source}` via `filter_query` so it follows the row through a re-sort. +- **The mirror path is resolved relative to the repo**, not the working directory, or an IDE run + configuration loads zero rows in silence. +- **Table width was never a Dash limit.** A page container with `maxWidth` will silently crop the + table; `DataTable` fills whatever its parent gives it. + +### The click-through CURIE listing + +Unnormalized CURIEs are grouped by `curie.problem()`, most actionable first: `malformed: `, +then `prefix unknown to the Biolink model`, then `no Babel clique for this identifier`. The prefix +check is a *signal*, not a proven cause — Babel decides coverage for itself and does not consult the +Biolink prefix map — so the label says what was actually checked. + +Within each problem, CURIEs are grouped by `curie.stem()`, everything up to the first digit. That is +what turns pathbank's 215,953 failures into 175,039 `PathBank:Reaction_…`, 31,182 +`PathBank:Compound_…` and 8,886 `PathBank:ProteinComplex_…`, and splits bgee's ENSEMBL failures by +species. Grouping declines above 12 groups and falls back to a flat list: InChIKeys contain no +digits, so 87 of them would otherwise make 87 groups of one. + +Headline figures in the summary list describe the whole build selection, not the visible rows — an +overall score that moved when you hid the fully-normalizing prefixes would be worse than useless. +CURIEs are counted once per source, so the all-builds total of 3,225,939 failures counts occurrences +(898,042 distinct). + +### Linking CURIEs + +`biolink-model-prefix-map.json` is **vendored** into the package (266 prefixes, 14 KB) so the app +works offline. Refresh it with: + +```bash +curl -sL https://raw.githubusercontent.com/biolink/biolink-model/master/src/biolink_model/prefixmaps/biolink-model-prefix-map.json \ + -o src/normalization_dashboard/biolink-model-prefix-map.json +``` + +The map covers 75.5% of the 898,042 distinct unnormalized CURIEs. The other 24 prefixes fall back to +`https://bioregistry.io/{curie}`, which resolves them — including PathBank, which is not in the +Biolink prefix map at all and is by itself 215,953 of those CURIEs. The `curies` package is not used: +the Biolink file is a flat prefix → URI-stem dict, so expansion is one dict lookup and a string +concat, and a case-insensitive index is a one-line comprehension. + +Malformed CURIEs get no link and a reason instead, because the malformation is often the whole +explanation for the normalization failure. Real species found in the data: `rhea:RHEA:13065` (double +prefix), `CL:0000089 ∩ UBERON:0000473` (post-composed class expression), `UniProtKB:B3DHD6 Q6XCC7` +(two accessions in one CURIE), `UniProtKB:` (empty local id). Only 24 of 898,042 are malformed at +this syntactic level — rare, but they cluster: intact's 2 RHEA failures are both double-prefixed. + +Current shape of the data through the loader: 587 raw rows, 31 sources, 84 case-insensitive +prefixes, 242 summary rows (241 for latest builds only — historical builds add almost nothing at the +(source, prefix) level). + +## Local mirror + +`./scripts/sync-kgx-normalization.sh` mirrors those three file types plus `latest-build.json` into +`data/kgx-storage.ci.transltr.io/` — a directory named after the host they came from, so it stays +obvious where a local copy originated. The nodes/edges files are excluded by the `--include` +filters, which apply to the listing walk, so they are never fetched. Full runbook, variants and +troubleshooting: [docs/Loading.md](docs/Loading.md). + +`/data/` is gitignored, so use it as the scratch space for one-off jobs — intermediate results, +downloaded samples, ad-hoc query output — rather than `/tmp`. It survives reboots and stays next to +the code, so a one-off job can be picked up or re-run later instead of being redone from scratch. + +## Conventions + +- Add a dependency only when something actually needs it. `dash` is the only one; `curies` was + considered and skipped because the Biolink prefix map is a flat dict. +- The downloader stays `rclone` — it already does listing, filtering, incremental sync, pruning, + retries, and concurrency. +- Data files are never committed; only the small derived JSON summaries will be. +- Derived values are recomputed rather than trusted: `success_rate` comes from the counts, not from + the field in the file. +- PR titles become release notes, so they describe the change and its effect — never "WIP" or + "Initial implementation of X". diff --git a/README.md b/README.md index 149dc61..a6127fd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,67 @@ # NormalizationDashboard -A dashboard to track normalization across KGX releases + +A dashboard to track normalization across KGX releases. + +The NCATS Translator DINGO pipeline normalizes KGX node CURIEs through +[NodeNorm](https://github.com/biothings/NodeNormalizationAPI), which uses cliques from +[Babel](https://github.com/NCATSTranslator/Babel), and publishes the results to +[KGX Storage](https://kgx-storage.ci.transltr.io/). This project summarizes how that normalization +went: which prefixes normalized to what, and which CURIEs failed to normalize (so they can be +prioritized for a future Babel ingest). + +## Quick start + +```bash +brew install rclone # or apt install rclone +uv sync +./scripts/sync-kgx-normalization.sh # ~970 MB into data/ (gitignored), a few minutes +uv run normalization-dashboard # http://127.0.0.1:8050 +``` + +The sync mirrors *only* the normalization files from each KGX build — the multi-gigabyte +`normalized_nodes.jsonl` / `normalized_edges.jsonl` files are never fetched — preserving KGX +Storage's folder layout and pruning anything that disappears upstream. + +**[docs/Loading.md](docs/Loading.md)** has the full runbook: prerequisites, sync variants (including +a ~56 MB mirror that skips the normalization maps), what lands where, how to keep it current, +hosting, and troubleshooting. + +## The dashboard + +A local [Dash](https://plotly.com/dash/) app. The first view is every data source × CURIE prefix, +sorted from the worst normalization rate to the best — the ranking that says which prefixes Babel +should ingest next. Sort by **Failed** instead to rank by how many CURIEs are actually at stake: a +prefix at 0% of 3 CURIEs and one at 0% of 216,000 sort identically by percentage. + +Prefixes are pooled case-insensitively, because NodeNorm resolves CURIE prefixes case-insensitively +(`ENSEMBL:`, `Ensembl:` and `ensembl:` all resolve alike). Prefixes that fully normalize are hidden +by default, and each version links back to the normalization output directory in KGX Storage that +the numbers came from. + +Each row carries up to five **Example** CURIEs that actually failed to normalize, spelled and cased +as the source spells them — often the fastest explanation of a 0% row. PathBank's failures turn out +to be `PathBank:Reaction_13124` and `PathBank:Compound_102409`, not pathway identifiers at all. + +**Click a row** to list the CURIEs that failed to normalize for that source and prefix, grouped by +*why* they plausibly failed — malformed first (`rhea:RHEA:13065`, `CL:0000089 ∩ UBERON:0000473`, +`UniProtKB:B3DHD6 Q6XCC7`), then prefixes the Biolink model has never heard of, then the ordinary +"no Babel clique" remainder. Within each reason they are broken down by CURIE shape, which is how +pathbank's 215,953 failures resolve into 175,039 `PathBank:Reaction_…`, 31,182 `PathBank:Compound_…` +and 8,886 `PathBank:ProteinComplex_…`. Every CURIE links out — via the Biolink prefix map, falling +back to [Bioregistry](https://bioregistry.io/) — so you can check what the identifier actually is. + +It runs locally, which keeps individual CURIEs off the public web and leaves the deployment question +(GitHub Pages export, Kubernetes, or folding into another Translator dashboard) open. `PORT=8051` +runs a second instance alongside the first. + +## Development + +```bash +uv sync +uv run python tests/test_loader.py # invariants against the real mirror +uv run python tests/test_curie.py # CURIE linking, malformed detection, grouping +``` + +`src/normalization_dashboard/loader.py` has no Dash imports and returns plain `list[dict]`, so a +notebook or a future static-JSON exporter can reuse it without touching the app. See +[CLAUDE.md](CLAUDE.md) for how KGX Storage behaves and what each file contains. diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/Loading.md b/docs/Loading.md new file mode 100644 index 0000000..6b9ed19 --- /dev/null +++ b/docs/Loading.md @@ -0,0 +1,148 @@ +# Loading the data and running the dashboard + +Everything needed to get from a fresh checkout to a working dashboard. The short version: + +```bash +brew install rclone # or apt install rclone +uv sync +./scripts/sync-kgx-normalization.sh # ~970 MB, a few minutes +uv run normalization-dashboard # http://127.0.0.1:8050 +``` + +## Prerequisites + +| | | +|---|---| +| [uv](https://docs.astral.sh/uv/) | runs the project; `uv sync` installs Dash | +| [rclone](https://rclone.org/) | the entire downloader — `brew install rclone` / `apt install rclone` | +| disk | ~970 MB for a full mirror, or ~56 MB without the normalization maps | +| network | HTTPS to `kgx-storage.ci.transltr.io`; **no AWS credentials needed** | + +No AWS credentials are needed, and none would help for listing: the `s3://kgx-translator-ingests` +bucket is not anonymously listable, so the site's HTML directory listings are the only public index. +rclone's `:http:` backend walks them. + +## Step 1 — mirror the normalization data + +```bash +./scripts/sync-kgx-normalization.sh +``` + +One `rclone sync` call. It walks every build directory in KGX Storage and downloads only four +kinds of file, preserving the remote folder layout and deleting local files that have disappeared +upstream: + +- `normalization-metadata.json` — per-prefix counts, the input for the whole table +- `normalization_failures.txt` — one unnormalized CURIE per line +- `normalization_map.json` — source CURIE → normalized CURIE, for the future renormalization work +- `latest-build.json` — which build is current for each source + +The multi-gigabyte `normalized_nodes.jsonl` and `normalized_edges.jsonl` files are **never fetched**. +The `--include` filters apply to the listing walk, so only directory HTML is requested for them. + +### Variants + +```bash +# See what would transfer, without downloading +./scripts/sync-kgx-normalization.sh /tmp/kgxtest --dry-run + +# Skip the maps: ~56 MB instead of ~970 MB, enough for everything the dashboard shows today +./scripts/sync-kgx-normalization.sh data/kgx-storage.ci.transltr.io \ + --exclude "**/normalization_map.json" + +# Mirror somewhere else +./scripts/sync-kgx-normalization.sh /some/other/path +``` + +Any argument after the destination is passed straight through to `rclone`. + +### What you end up with + +``` +data/kgx-storage.ci.transltr.io/ +├── {source}/ +│ ├── latest-build.json +│ └── {source_version}/ +│ └── transform_{hash}/ +│ └── normalization_{babel}_{nn}_{code}_{conflated}_{strict}/ +│ ├── normalization-metadata.json +│ ├── normalization_failures.txt +│ └── normalization_map.json +``` + +As of August 2026: 31 sources, 90 build directories, ~970 MB — 298 files, of which 297 arrive (see +the ubergraph 502 under Troubleshooting). The directory is named after the host it came from, and +`/data/` is gitignored, so nothing here is ever committed. + +Re-running the script is incremental: unchanged files are skipped on size and modification time, so +a no-op sync transfers nothing. The listing walk still takes a few minutes, since every directory +must be fetched as HTML. + +## Step 2 — check the mirror + +```bash +uv run python tests/test_loader.py # OK: 587 rows over 31 sources, 84 case-insensitive prefixes… +uv run python tests/test_curie.py # OK: 266 Biolink prefixes loaded +``` + +No framework, no fixtures — these assert invariants against the real mirror (counts reconcile, +rates match the counts, exactly one latest build per source, summary values are all scalars). The +loader check skips cleanly if the mirror has not been synced yet. + +## Step 3 — run the dashboard + +```bash +uv run normalization-dashboard # http://127.0.0.1:8050 +PORT=8051 uv run normalization-dashboard # second instance alongside the first +``` + +Startup reads the 90 metadata files and scans the 87 failures files once to collect example CURIEs — +3.2M lines, under a second. Nothing is cached to disk; restart to pick up a fresh sync. + +The mirror is found relative to the repository even when the app is launched from another working +directory, so an IDE run configuration works without extra setup. If no mirror is found at all, the +app fails at startup naming the sync script rather than rendering an empty table. + +## Keeping it current + +- **New KGX builds**: re-run `./scripts/sync-kgx-normalization.sh`, then restart the app. +- **Biolink prefix map** (vendored so the app works offline, 266 prefixes, 14 KB): + + ```bash + curl -sL https://raw.githubusercontent.com/biolink/biolink-model/master/src/biolink_model/prefixmaps/biolink-model-prefix-map.json \ + -o src/normalization_dashboard/biolink-model-prefix-map.json + ``` + + It decides which CURIEs link directly to their source; the rest fall back to `bioregistry.io`. + +## Hosting + +**There is no static export yet.** The dashboard is a local Dash server, which is deliberate for +now: individual unnormalized CURIEs stay off the public web while we work out what the dashboard +should show, and it keeps the door open to folding this into another Translator dashboard later. + +When we do want it hosted, the shape of the work is already set up for it: + +- `loader.py` has no Dash imports and returns plain `list[dict]`, so a static-JSON exporter can + reuse `load_rows()`, `summarize()` and `load_failure_examples()` without touching the app. +- The prefix summary is genuinely small — 242 source/prefix rows, well under a megabyte as JSON — + so a GitHub Pages page reading a committed JSON file is viable for goal 1. +- The 898,042 distinct unnormalized CURIEs are the part that would need a decision, both for size + and for whether they should be public at all. + +## Troubleshooting + +**`502 Bad Gateway` on ubergraph's `normalization_map.json`, sync exits non-zero.** Expected, and +upstream. That one file is 247 MB and the origin appears to buffer JSON responses in memory; a 48 MB +map from the same source downloads fine. The sync completes 297 of 298 files. Add +`--exclude "**/ubergraph/**/normalization_map.json"` if the non-zero exit is a nuisance. + +**`Port 8050 is in use by another program.`** Another instance is still running. `lsof -ti:8050` +finds it, or use `PORT=8051`. + +**`No KGX Storage mirror at …`** The sync has not been run, or was pointed somewhere else. Run +`./scripts/sync-kgx-normalization.sh` from the repository root. + +**The table is empty, or shows fewer rows than expected.** Check the mirror actually has metadata +files: `find data/kgx-storage.ci.transltr.io -name normalization-metadata.json | wc -l` should +report 90. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8f9752b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "normalization-dashboard" +version = "0.1.0" +description = "A dashboard to track normalization across KGX releases" +readme = "README.md" +authors = [ + { name = "Gaurav Vaidya", email = "gaurav@renci.org" } +] +requires-python = ">=3.12" +dependencies = [ + "dash>=4.4.1", +] + +[project.scripts] +normalization-dashboard = "normalization_dashboard:main" + +[build-system] +requires = ["uv_build>=0.12.3,<0.13.0"] +build-backend = "uv_build" diff --git a/scripts/sync-kgx-normalization.sh b/scripts/sync-kgx-normalization.sh new file mode 100755 index 0000000..34952eb --- /dev/null +++ b/scripts/sync-kgx-normalization.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Mirror only the normalization artifacts from KGX Storage, preserving its folder layout. +# Downloads new/changed files and deletes local files that no longer exist upstream. +# +# Usage: ./scripts/sync-kgx-normalization.sh [DEST] [extra rclone args...] +# e.g. ./scripts/sync-kgx-normalization.sh /tmp/kgxtest --dry-run +# ./scripts/sync-kgx-normalization.sh data/kgx-storage.ci.transltr.io --exclude "**/normalization_map.json" +# +# Requires rclone (brew install rclone). The S3 bucket behind KGX Storage is not +# anonymously listable, so we walk the site's HTML listings via rclone's http backend. +set -euo pipefail + +DEST="${1:-data/kgx-storage.ci.transltr.io}" + +rclone sync --http-url https://kgx-storage.ci.transltr.io :http:data/ "$DEST" \ + --include "**/normalization-metadata.json" \ + --include "**/normalization_failures.txt" \ + --include "**/normalization_map.json" \ + --include "**/latest-build.json" \ + --checkers 16 --transfers 8 --progress --stats-one-line "${@:2}" diff --git a/src/normalization_dashboard/__init__.py b/src/normalization_dashboard/__init__.py new file mode 100644 index 0000000..28cb2d2 --- /dev/null +++ b/src/normalization_dashboard/__init__.py @@ -0,0 +1,3 @@ +from normalization_dashboard.app import main + +__all__ = ["main"] diff --git a/src/normalization_dashboard/app.py b/src/normalization_dashboard/app.py new file mode 100644 index 0000000..0ede615 --- /dev/null +++ b/src/normalization_dashboard/app.py @@ -0,0 +1,330 @@ +"""Local Dash app: every data source x CURIE prefix, worst normalization first.""" + +import os +from collections import defaultdict + +from dash import Dash, Input, Output, callback, dash_table, dcc, html + +from normalization_dashboard import curie, loader +from normalization_dashboard.loader import load_failure_examples, load_rows, summarize + +EXAMPLE_COUNT = 5 + +# (key, header, markdown?) -- CURIE Prefix leads, it is the row's subject. +COLUMNS = [ + ("prefix", "CURIE Prefix", False), + ("source", "Source", False), + ("source_versions_md", "Version", True), + # No "Observed as" column: it equals the prefix in every row today, and the + # Example columns show the original casing anyway. summarize() still carries + # the field for whenever one source does spell a prefix two ways. + ("total", "Total", False), + ("succeeded", "Succeeded", False), + ("failed", "Failed", False), + ("success_rate", "Success %", False), + *[(f"example_{n}", f"Example {n}", True) for n in range(1, EXAMPLE_COUNT + 1)], + # No "Normalized to" column: at 200+ characters for semmeddb it was the one + # value wide enough to force a scrollbar by itself, and where a prefix + # normalizes *to* is a renormalization-diff question, not a today question. + # summarize() still carries normalized_to_str for when that work starts. +] + +# Red at 0% shading to green at 100%, so the ranking is scannable without reading numbers. +RATE_SHADING = [ + { + "if": { + "column_id": "success_rate", + "filter_query": f"{{success_rate}} >= {low} && {{success_rate}} < {low + 20}", + }, + "backgroundColor": colour, + } + for low, colour in ( + (0, "#f8d0d0"), + (20, "#fae0cd"), + (40, "#fdf3cd"), + (60, "#e8f2d5"), + (80, "#d5ecd8"), + ) +] + +# The prefix column is effectively the row header, so it reads like one. +PREFIX_STYLE = { + "if": {"column_id": "prefix"}, + "fontWeight": "600", + "fontFamily": "ui-monospace, monospace", + "backgroundColor": "#f4f4f6", +} + +# Clicking anywhere in a row highlights the whole row; the single active cell +# gets no colour of its own, since which cell you happened to hit means nothing. +ACTIVE_CELL_STYLE = {"if": {"state": "active"}, "backgroundColor": "transparent"} +SELECTED_ROW_COLOUR = "#dbe6ff" + +MAX_CURIES_SHOWN = 30 +EXAMPLES_PER_GROUP = 6 + +app = Dash(__name__) +ROWS = load_rows() +EXAMPLES = load_failure_examples(ROWS, limit=EXAMPLE_COUNT) + + +def table_rows(latest_only, hide_complete): + rows = [row for row in ROWS if row["is_latest"]] if latest_only else ROWS + summary = summarize(rows) + if hide_complete: + summary = [row for row in summary if row["success_rate"] < 100] + for row in summary: + examples = EXAMPLES.get((row["source"], row["prefix"]), ()) + for number in range(1, EXAMPLE_COUNT + 1): + example = examples[number - 1] if number <= len(examples) else None + row[f"example_{number}"] = curie.as_markdown(example) if example else "" + return summary + + +app.layout = html.Div( + # No maxWidth anywhere: the table wants every pixel the window has, and the + # intro should line up with it rather than sit in a narrow column. + style={"margin": "0 auto", "padding": "0 16px", "fontFamily": "system-ui, sans-serif"}, + children=[ + html.H1("Normalization by source and CURIE prefix"), + html.Ul(id="summary", style={"lineHeight": "1.7"}), + dcc.Checklist( + id="latest-only", + options=[{"label": " Latest build per source only", "value": "latest"}], + value=["latest"], + ), + dcc.Checklist(id="hide-complete", options=[], value=["hide"]), + html.Div(id="row-count", style={"margin": "8px 0"}), + dash_table.DataTable( + id="table", + columns=[ + {"name": name, "id": key, **({"presentation": "markdown"} if md else {})} + for key, name, md in COLUMNS + ], + markdown_options={"link_target": "_blank"}, + # Safety net for narrow windows: scroll the table, never the page. + style_table={"overflowX": "auto"}, + sort_action="native", + filter_action="native", + page_size=75, + sort_by=[{"column_id": "success_rate", "direction": "asc"}], + style_cell={ + "fontFamily": "system-ui, sans-serif", + "textAlign": "left", + "padding": "4px 8px", + }, + style_cell_conditional=[ + {"if": {"column_id": column}, "textAlign": "right"} + for column in ("total", "succeeded", "failed", "success_rate") + ], + style_data_conditional=[PREFIX_STYLE, *RATE_SHADING, ACTIVE_CELL_STYLE], + style_header={"fontWeight": "600"}, + ), + html.Div(id="failures", style={"marginTop": "24px"}), + ], +) + + +# Fixed notes, listed after the figures so the numbers come first. +NOTES = [ + "Prefixes are pooled case-insensitively, because NodeNorm resolves CURIE " + "prefixes case-insensitively.", + "Sort by Failed to rank by how many CURIEs are at stake rather than by " + "percentage: 0% of 3 and 0% of 215,953 rank identically by rate.", + "Click a row to list the CURIEs that failed to normalize, grouped by why " + "they plausibly failed.", + "CURIEs are counted once per source, so one appearing in three sources " + "counts three times.", +] + + +@callback( + Output("table", "data"), + Output("summary", "children"), + Output("row-count", "children"), + Output("hide-complete", "options"), + Input("latest-only", "value"), + Input("hide-complete", "value"), +) +def update_table(latest_only, hide_complete): + latest_only = "latest" in latest_only + everything = table_rows(latest_only, False) + data = table_rows(latest_only, "hide" in hide_complete) + complete = sum(1 for row in everything if row["success_rate"] == 100) + return ( + data, + summary_items(everything), + f"{len(data):,} rows displayed", + [{"label": f" Hide prefixes that fully normalize (n={complete})", "value": "hide"}], + ) + + +def summary_items(everything): + """The headline figures, then the fixed notes, as one scannable list. + + These describe the whole build selection rather than the visible rows: an + overall score that moved when you hid the fully-normalizing prefixes would + be worse than useless. + """ + total = sum(row["total"] for row in everything) + succeeded = sum(row["succeeded"] for row in everything) + figures = [ + f"{succeeded:,} of {total:,} CURIEs normalized " + f"({100 * succeeded / total:.1f}%)", + f"{total - succeeded:,} CURIEs failed to normalize", + f"{len({row['source'] for row in everything})} sources, " + f"{len({row['prefix'] for row in everything})} CURIE prefixes, " + f"{len(everything):,} source/prefix pairs", + ] + return [html.Li(html.Strong(figure)) for figure in figures] + [ + html.Li(note) for note in NOTES + ] + + +def failures_paths(source, prefix, latest_only): + """The failures files behind one summary row. + + Looked up here rather than carried in the table: DataTable cells may only + hold scalars, and the browser rejects the whole table if a row holds a list. + """ + return sorted( + { + row["failures_path"] + for row in ROWS + if row["source"] == source + and row["prefix_key"] == prefix + and row["failures_path"] + and (row["is_latest"] or not latest_only) + } + ) + + +@callback( + Output("table", "style_data_conditional"), + Input("table", "active_cell"), + Input("table", "derived_viewport_data"), +) +def highlight_selected_row(active_cell, data): + """Colour the whole clicked row, matched on identity rather than position. + + A row index would follow the viewport rather than the row once the table is + re-sorted or filtered. + """ + styles = [PREFIX_STYLE, *RATE_SHADING, ACTIVE_CELL_STYLE] + if active_cell and data and active_cell["row"] < len(data): + row = data[active_cell["row"]] + styles.append( + { + "if": { + "filter_query": f'{{prefix}} = "{row["prefix"]}" ' + f'&& {{source}} = "{row["source"]}"' + }, + "backgroundColor": SELECTED_ROW_COLOUR, + } + ) + return styles + + +@callback( + Output("failures", "children"), + Input("table", "active_cell"), + # The viewport, not `data`: active_cell.row indexes the sorted, filtered page. + Input("table", "derived_viewport_data"), + Input("latest-only", "value"), +) +def show_failures(active_cell, data, latest_only): + """List the unnormalized CURIEs behind the clicked row. + + Read on demand rather than indexed up front: the biggest failures file is + under 7 MB, and preloading all 87 of them would cost 3.2M lines for nothing. + """ + if not active_cell or not data or active_cell["row"] >= len(data): + return None + row = data[active_cell["row"]] + + prefix = row["prefix"] + curies = [] + for path in failures_paths(row["source"], prefix, "latest" in latest_only): + with open(path) as failures: + curies.extend( + line.strip() + for line in failures + if line.strip() and line.split(":")[0].strip().upper() == prefix + ) + curies = sorted(set(curies)) + + if not curies: + return html.P( + f"No unnormalized CURIEs recorded for {prefix} in {row['source']} " + "(some builds ship no normalization_failures.txt).", + style={"color": "#555"}, + ) + + by_problem = defaultdict(list) + for value in curies: + by_problem[curie.problem(value)].append(value) + ordered = sorted( + by_problem.items(), key=lambda item: (curie.problem_rank(item[0]), -len(item[1])) + ) + return [ + html.H2( + f"{len(curies):,} unnormalized {prefix} CURIEs in {row['source']}", + style={"fontSize": "1.2em"}, + ), + html.Ul( + [_problem_item(label, members) for label, members in ordered], + style={"lineHeight": "1.8"}, + ), + ] + + +def _problem_item(label, members): + """One reason these CURIEs failed, broken down by CURIE shape underneath. + + A single shape is rendered inline: nesting one bullet under another says + nothing. + """ + heading = html.Strong( + f"{len(members):,} — {label}", + style={"color": "#a33"} if label.startswith(curie.MALFORMED) else None, + ) + shapes = curie.group_by_stem(members) + if shapes and len(shapes) > 1: + return html.Li([heading, html.Ul([_shape_item(*shape) for shape in shapes])]) + if shapes: + return html.Li([heading, " ", _examples(members)]) + # No shared shape to group on, so just list them, spread across the whole set. + listed = loader.spread(members, MAX_CURIES_SHOWN) + note = ( + f" (showing {len(listed)} spread across {len(members):,})" + if len(listed) < len(members) + else "" + ) + return html.Li( + [ + heading, + note, + html.Ul([html.Li(_examples([value], 1)) for value in listed]), + ] + ) + + +def _shape_item(shape, count, members): + return html.Li([html.Strong(f"{count:,} × {shape}… "), _examples(members)]) + + +def _examples(members, limit=EXAMPLES_PER_GROUP): + """A few members spread across the whole group, linked.""" + return dcc.Markdown( + " · ".join( + curie.as_markdown(value, explain=True) + for value in loader.spread(members, limit) + ), + link_target="_blank", + style={"display": "inline"}, + ) + + +def main() -> None: + # PORT= lets a second instance run alongside one already on 8050. + app.run(debug=True, port=int(os.environ.get("PORT", 8050))) diff --git a/src/normalization_dashboard/biolink-model-prefix-map.json b/src/normalization_dashboard/biolink-model-prefix-map.json new file mode 100644 index 0000000..781d845 --- /dev/null +++ b/src/normalization_dashboard/biolink-model-prefix-map.json @@ -0,0 +1,268 @@ +{ + "AGRKB": "https://www.alliancegenome.org/", + "APO": "http://purl.obolibrary.org/obo/APO_", + "AspGD": "http://www.aspergillusgenome.org/cgi-bin/locus.pl?dbid=", + "BFO": "http://purl.obolibrary.org/obo/BFO_", + "BIGG.METABOLITE": "http://identifiers.org/bigg.metabolite/", + "BIGG.REACTION": "http://identifiers.org/bigg.reaction/", + "BIOGRID": "http://identifiers.org/biogrid/", + "BIOSAMPLE": "http://identifiers.org/biosample/", + "BSPO": "http://purl.obolibrary.org/obo/BSPO_", + "BTO": "http://purl.obolibrary.org/obo/BTO_", + "CAID": "http://reg.clinicalgenome.org/redmine/projects/registry/genboree_registry/by_caid?caid=", + "CARO": "http://purl.obolibrary.org/obo/CARO_", + "CAS": "http://identifiers.org/cas/", + "CATH": "http://identifiers.org/cath/", + "CATH.SUPERFAMILY": "http://identifiers.org/cath.superfamily/", + "CDD": "http://identifiers.org/cdd/", + "CHADO": "http://gmod.org/wiki/Chado/", + "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", + "CHEMBL.COMPOUND": "http://identifiers.org/chembl.compound/", + "CHEMBL.MECHANISM": "https://www.ebi.ac.uk/chembl/mechanism/inspect/", + "CHEMBL.TARGET": "http://identifiers.org/chembl.target/", + "CID": "http://pubchem.ncbi.nlm.nih.gov/compound/", + "CIO": "http://purl.obolibrary.org/obo/CIO_", + "CL": "http://purl.obolibrary.org/obo/CL_", + "CLINICALTRIALS": "http://identifiers.org/clinicaltrials/", + "CLINVAR": "http://identifiers.org/clinvar", + "CLO": "http://purl.obolibrary.org/obo/CLO_", + "COAR_RESOURCE": "http://purl.org/coar/resource_type/", + "COG": "https://www.ncbi.nlm.nih.gov/research/cog-project/", + "CPT": "https://www.ama-assn.org/practice-management/cpt/", + "CTD": "http://ctdbase.org/", + "CTD.CHEMICAL": "http://ctdbase.org/detail.go?type=chem&acc=", + "CTD.DISEASE": "http://ctdbase.org/detail.go?type=disease&db=MESH&acc=", + "CTD.GENE": "http://ctdbase.org/detail.go?type=gene&acc=", + "ChemBank": "http://chembank.broadinstitute.org/chemistry/viewMolecule.htm?cbid=", + "ComplexPortal": "https://www.ebi.ac.uk/complexportal/complex/", + "DBSNP": "http://identifiers.org/dbsnp/", + "DDANAT": "http://purl.obolibrary.org/obo/DDANAT_", + "DGIdb": "https://www.dgidb.org/interaction_types", + "DOID": "http://purl.obolibrary.org/obo/DOID_", + "DOID-PROPERTY": "http://purl.obolibrary.org/obo/doid#", + "DRUGBANK": "http://identifiers.org/drugbank/", + "DrugCentral": "http://drugcentral.org/drugcard/", + "EC": "http://www.enzyme-database.org/query.php?ec=", + "ECO": "http://purl.obolibrary.org/obo/ECO_", + "ECTO": "http://purl.obolibrary.org/obo/ECTO_", + "EDAM-DATA": "http://edamontology.org/data_", + "EDAM-FORMAT": "http://edamontology.org/format_", + "EDAM-OPERATION": "http://edamontology.org/operation_", + "EDAM-TOPIC": "http://edamontology.org/topic_", + "EFO": "http://www.ebi.ac.uk/efo/EFO_", + "EGGNOG": "http://identifiers.org/eggnog/", + "EMAPA": "http://purl.obolibrary.org/obo/EMAPA_", + "ENSEMBL": "http://identifiers.org/ensembl/", + "ENVO": "http://purl.obolibrary.org/obo/ENVO_", + "ExO": "http://purl.obolibrary.org/obo/ExO_", + "FAO": "http://purl.obolibrary.org/obo/FAO_", + "FB": "http://identifiers.org/fb/", + "FBbt": "http://purl.obolibrary.org/obo/FBbt_", + "FBcv": "http://purl.obolibrary.org/obo/FBcv_", + "FBdv": "http://purl.obolibrary.org/obo/FBdv_", + "FMA": "http://purl.obolibrary.org/obo/FMA_", + "FOODON": "http://purl.obolibrary.org/obo/FOODON_", + "FYECO": "https://www.pombase.org/term/", + "FYPO": "http://purl.obolibrary.org/obo/FYPO_", + "GENEPIO": "http://purl.obolibrary.org/obo/GENEPIO_", + "GENO": "http://purl.obolibrary.org/obo/GENO_", + "GO": "http://purl.obolibrary.org/obo/GO_", + "GOLD.META": "http://identifiers.org/gold.meta/", + "GOP": "http://purl.obolibrary.org/obo/go#", + "GOREL": "http://purl.obolibrary.org/obo/GOREL_", + "GSID": "https://scholar.google.com/citations?user=", + "GTEx": "https://www.gtexportal.org/home/gene/", + "GTOPDB": "https://www.guidetopharmacology.org/GRAC/LigandDisplayForward?ligandId=", + "HAMAP": "http://identifiers.org/hamap/", + "HANCESTRO": "http://www.ebi.ac.uk/ancestro/ancestro_", + "HCPCS": "http://purl.bioontology.org/ontology/HCPCS/", + "HGNC": "http://identifiers.org/hgnc/", + "HGNC.FAMILY": "http://identifiers.org/hgnc.family/", + "HMDB": "http://identifiers.org/hmdb/", + "HP": "http://purl.obolibrary.org/obo/HP_", + "HsapDv": "http://purl.obolibrary.org/obo/HsapDv_", + "IAO": "http://purl.obolibrary.org/obo/IAO_", + "ICD10": "https://icd.who.int/browse10/2016/en#/", + "ICD9": "http://translator.ncats.nih.gov/ICD9_", + "IDO": "http://purl.obolibrary.org/obo/IDO_", + "INCHI": "http://identifiers.org/inchi/", + "INCHIKEY": "http://identifiers.org/inchikey/", + "INO": "http://purl.obolibrary.org/obo/INO_", + "INTACT": "http://identifiers.org/intact/", + "IUPHAR.FAMILY": "http://identifiers.org/iuphar.family/", + "KEGG": "http://www.kegg.jp/entry/", + "KEGG.BRITE": "https://bioregistry.io/kegg.brite:", + "KEGG.COMPOUND": "http://identifiers.org/kegg.compound/", + "KEGG.DISEASE": "http://identifiers.org/kegg.disease/", + "KEGG.DRUG": "http://identifiers.org/kegg.drug/", + "KEGG.ENVIRON": "http://identifiers.org/kegg.environ/", + "KEGG.GENES": "https://bioregistry.io/kegg.genes:bsu:", + "KEGG.GLYCAN": "http://identifiers.org/kegg.glycan/", + "KEGG.MODULE": "http://identifiers.org/kegg.module/", + "KEGG.ORTHOLOGY": "http://identifiers.org/kegg.orthology/", + "KEGG.PATHWAY": "https://bioregistry.io/kegg.pathway:", + "KEGG.RCLASS": "https://www.genome.jp/dbget-bin/www_bget?rc:", + "KEGG.REACTION": "http://identifiers.org/kegg.reaction/", + "LOINC": "http://loinc.org/rdf/", + "MA": "http://purl.obolibrary.org/obo/MA_", + "MAXO": "http://purl.obolibrary.org/obo/MAXO_", + "MEDDRA": "http://identifiers.org/meddra/", + "MESH": "http://id.nlm.nih.gov/mesh/", + "METANETX.REACTION": "https://www.metanetx.org/equa_info/", + "METPO": "https://w3id.org/metpo/", + "MGI": "http://identifiers.org/mgi/", + "MI": "http://purl.obolibrary.org/obo/MI_", + "MIR": "http://identifiers.org/mir/", + "MOD": "http://purl.obolibrary.org/obo/MOD_", + "MONDO": "http://purl.obolibrary.org/obo/MONDO_", + "MP": "http://purl.obolibrary.org/obo/MP_", + "MPATH": "http://purl.obolibrary.org/obo/MPATH_", + "MSigDB": "https://www.gsea-msigdb.org/gsea/msigdb/", + "MmusDv": "http://purl.obolibrary.org/obo/MMUSDV_", + "NBO": "http://purl.obolibrary.org/obo/NBO_", + "NBO-PROPERTY": "http://purl.obolibrary.org/obo/nbo#", + "NCBIGene": "http://identifiers.org/ncbigene/", + "NCBITaxon": "http://purl.obolibrary.org/obo/NCBITaxon_", + "NCIT": "http://purl.obolibrary.org/obo/NCIT_", + "NCIT-OBO": "http://purl.obolibrary.org/obo/ncit#", + "NDC": "http://identifiers.org/ndc/", + "NDDF": "http://purl.bioontology.org/ontology/NDDF/", + "NLMID": "https://www.ncbi.nlm.nih.gov/nlmcatalog/?term=", + "OBAN": "http://purl.org/oban/", + "OBI": "http://purl.obolibrary.org/obo/OBI_", + "OGMS": "http://purl.obolibrary.org/obo/OGMS_", + "OMIM": "http://purl.obolibrary.org/obo/OMIM_", + "OMIM.PS": "https://www.omim.org/phenotypicSeries/", + "OMIT": "http://purl.obolibrary.org/obo/OMIT_", + "ORCID": "https://orcid.org/", + "PANTHER.FAMILY": "http://www.pantherdb.org/panther/family.do?clsAccession=", + "PANTHER.PATHWAY": "http://identifiers.org/panther.pathway/", + "PATO": "http://purl.obolibrary.org/obo/PATO_", + "PCO": "http://purl.obolibrary.org/obo/PCO_", + "PFAM": "http://identifiers.org/pfam/", + "PHARMGKB.DISEASE": "https://www.pharmgkb.org/disease/", + "PHARMGKB.DRUG": "https://www.pharmgkb.org/chemical/", + "PHARMGKB.GENE": "https://www.pharmgkb.org/gene/", + "PHARMGKB.PATHWAYS": "https://www.pharmgkb.org/pathway/", + "PHARMGKB.VARIANT": "https://www.pharmgkb.org/variant/", + "PHAROS": "http://pharos.nih.gov", + "PIRSF": "http://identifiers.org/pirsf/", + "PMC": "http://europepmc.org/articles/PMC", + "PMCID": "http://www.ncbi.nlm.nih.gov/pmc/", + "PMID": "http://www.ncbi.nlm.nih.gov/pubmed/", + "PO": "http://purl.obolibrary.org/obo/PO_", + "PR": "http://purl.obolibrary.org/obo/PR_", + "PRINTS": "http://identifiers.org/prints/", + "PRODOM": "http://identifiers.org/prodom/", + "PROSITE": "http://identifiers.org/prosite/", + "PUBCHEM.COMPOUND": "http://identifiers.org/pubchem.compound/", + "PUBCHEM.SUBSTANCE": "http://identifiers.org/pubchem.substance/", + "PW": "http://purl.obolibrary.org/obo/PW_", + "PathWhiz": "http://smpdb.ca/pathways/#", + "PomBase": "https://www.pombase.org/gene/", + "REACT": "http://www.reactome.org/PathwayBrowser/#/", + "REPODB": "http://apps.chiragjpgroup.org/repoDB/", + "RFAM": "http://identifiers.org/rfam/", + "RGD": "http://identifiers.org/rgd/", + "RHEA": "http://identifiers.org/rhea/", + "RNACENTRAL": "http://identifiers.org/rnacentral/", + "RO": "http://purl.obolibrary.org/obo/RO_", + "RXCUI": "https://mor.nlm.nih.gov/RxNav/search?searchBy=RXCUI&searchTerm=", + "RXNORM": "http://purl.bioontology.org/ontology/RXNORM/", + "ResearchID": "https://publons.com/researcher/", + "SEED.REACTION": "https://modelseed.org/biochem/reactions/", + "SEMMEDDB": "https://skr3.nlm.nih.gov/SemMedDB", + "SEPIO": "http://purl.obolibrary.org/obo/SEPIO_", + "SGD": "http://identifiers.org/sgd/", + "SIDER.DRUG": "http://identifiers.org/sider.drug/", + "SIO": "http://semanticscience.org/resource/SIO_", + "SMART": "http://identifiers.org/smart/", + "SMPDB": "http://identifiers.org/smpdb/", + "SNOMED": "http://purl.obolibrary.org/obo/SNOMED_", + "SNOMEDCT": "http://snomed.info/id/", + "SO": "http://purl.obolibrary.org/obo/SO_", + "SPDI": "https://api.ncbi.nlm.nih.gov/variation/v0/spdi/", + "STATO": "http://purl.obolibrary.org/obo/STATO_", + "STY": "http://purl.bioontology.org/ontology/STY/", + "SUPFAM": "http://identifiers.org/supfam/", + "ScopusID": "https://www.scopus.com/authid/detail.uri?authorId=", + "TAXRANK": "http://purl.obolibrary.org/obo/TAXRANK_", + "TCDB": "http://identifiers.org/tcdb/", + "TIGRFAM": "http://identifiers.org/tigrfam/", + "TO": "http://purl.obolibrary.org/obo/TO_", + "UBERGRAPH": "http://translator.renci.org/ubergraph-axioms.ofn#", + "UBERON": "http://purl.obolibrary.org/obo/UBERON_", + "UBERON_CORE": "http://purl.obolibrary.org/obo/uberon/core#", + "UBERON_NONAMESPACE": "http://purl.obolibrary.org/obo/core#", + "UMLS": "http://identifiers.org/umls/", + "UMLSSG": "https://lhncbc.nlm.nih.gov/semanticnetwork/download/sg_archive/SemGroups-v04.txt", + "UNII": "http://identifiers.org/unii/", + "UNIMOD": "http://identifiers.org/unimod/", + "UNIPROT.ISOFORM": "http://purl.uniprot.org/isoforms/", + "UO-PROPERTY": "http://purl.obolibrary.org/obo/uo#", + "UPHENO": "http://purl.obolibrary.org/obo/UPHENO_", + "UniProtKB": "http://purl.uniprot.org/uniprot/", + "VANDF": "https://www.nlm.nih.gov/research/umls/sourcereleasedocs/current/VANDF/", + "VMC": "https://github.com/ga4gh/vr-spec/", + "WB": "http://identifiers.org/wb/", + "WBPhenotype": "http://purl.obolibrary.org/obo/WBPhenotype_", + "WBVocab": "http://bio2rdf.org/wormbase_vocabulary", + "WBbt": "http://purl.obolibrary.org/obo/WBbt_", + "WBls": "http://purl.obolibrary.org/obo/WBls_", + "WIKIDATA": "https://www.wikidata.org/entity/", + "WIKIDATA_PROPERTY": "https://www.wikidata.org/prop/", + "WIKIPATHWAYS": "http://identifiers.org/wikipathways/", + "WormBase": "https://www.wormbase.org/get?name=", + "XAO": "http://purl.obolibrary.org/obo/XAO_", + "XCO": "http://purl.obolibrary.org/obo/XCO_", + "XPO": "http://purl.obolibrary.org/obo/XPO_", + "Xenbase": "http://www.xenbase.org/gene/showgene.do?method=display&geneId=", + "ZFA": "http://purl.obolibrary.org/obo/ZFA_", + "ZFIN": "http://identifiers.org/zfin/", + "ZFS": "http://purl.obolibrary.org/obo/ZFS_", + "ZP": "http://purl.obolibrary.org/obo/ZP_", + "apollo": "https://github.com/GMOD/Apollo", + "biolink": "https://w3id.org/biolink/vocab/", + "bioschemas": "https://bioschemas.org/", + "dcat": "http://www.w3.org/ns/dcat#", + "dcid": "https://datacommons.org/browser/", + "dct": "http://purl.org/dc/terms/", + "dctypes": "http://purl.org/dc/dcmitype/", + "dictyBase": "http://dictybase.org/gene/", + "doi": "https://doi.org/", + "fabio": "http://purl.org/spar/fabio/", + "faldo": "http://biohackathon.org/resource/faldo#", + "foaf": "http://xmlns.com/foaf/0.1/", + "foodb.compound": "http://foodb.ca/compounds/", + "foodb.food": "http://foodb.ca/foods/", + "gff3": "https://github.com/The-Sequence-Ontology/Specifications/blob/master/gff3.md#", + "gpi": "https://github.com/geneontology/go-annotation/blob/master/specs/gpad-gpi-2-0.md#", + "gtpo": "https://rdf.guidetopharmacology.org/ns/gtpo#", + "icd11": "http://id.who.int/icd/entity/", + "icd11.foundation": "http://id.who.int/icd/entity/", + "interpro": "https://www.ebi.ac.uk/interpro/entry/", + "isbn": "https://www.isbn-international.org/identifier/", + "isni": "https://isni.org/isni/", + "issn": "https://portal.issn.org/resource/ISSN/", + "linkml": "https://w3id.org/linkml/", + "medgen": "https://www.ncbi.nlm.nih.gov/medgen/", + "metacyc.reaction": "http://identifiers.org/metacyc.reaction:", + "mirbase": "http://identifiers.org/mirbase", + "mmmp.biomaps": "https://bioregistry.io/mmmp.biomaps:", + "ncats.bioplanet": "https://tripod.nih.gov/bioplanet/detail.jsp?pid=", + "ncats.drug": "https://drugs.ncats.io/drug/", + "oboInOwl": "http://www.geneontology.org/formats/oboInOwl#", + "orphanet": "http://www.orpha.net/ORDO/Orphanet_", + "os": "https://github.com/cmungall/owlstar/blob/master/owlstar.ttl", + "owl": "http://www.w3.org/2002/07/owl#", + "pav": "http://purl.org/pav/", + "prov": "http://www.w3.org/ns/prov#", + "qud": "http://qudt.org/1.1/schema/qudt#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "schema": "http://schema.org/", + "skos": "http://www.w3.org/2004/02/skos/core#", + "uspto-patent": "http://www.uspto.gov/patent/grant/v1/", + "wgs": "http://www.w3.org/2003/01/geo/wgs84_pos", + "xsd": "http://www.w3.org/2001/XMLSchema#" +} diff --git a/src/normalization_dashboard/curie.py b/src/normalization_dashboard/curie.py new file mode 100644 index 0000000..f6f52d0 --- /dev/null +++ b/src/normalization_dashboard/curie.py @@ -0,0 +1,127 @@ +"""Turn CURIEs into links, and spot the ones that are malformed. + +A CURIE that cannot be normalized is sometimes just missing from Babel, and +sometimes malformed in a way that explains the failure by itself +(`rhea:RHEA:13065`, `CL:0000089 ∩ UBERON:0000473`, `UniProtKB:B3DHD6 Q6XCC7`). +Telling those apart is the point of this module. +""" + +import json +import re +from collections import Counter +from pathlib import Path + +# Vendored from biolink-model so the app works offline; 266 prefixes, refresh with: +# curl -sL https://raw.githubusercontent.com/biolink/biolink-model/master/src/biolink_model/prefixmaps/biolink-model-prefix-map.json \ +# -o src/normalization_dashboard/biolink-model-prefix-map.json +PREFIX_MAP_PATH = Path(__file__).parent / "biolink-model-prefix-map.json" + +# Prefixes are matched case-insensitively, because NodeNorm resolves them that way. +PREFIX_MAP = { + prefix.upper(): uri_stem + for prefix, uri_stem in json.loads(PREFIX_MAP_PATH.read_text()).items() +} + +# Resolves nearly everything the Biolink map does not, including PathBank. +BIOREGISTRY_URL = "https://bioregistry.io/{}" + + +def malformed(curie): + """Why this is not a well-formed CURIE, or None if it looks fine.""" + if any(character.isspace() for character in curie): + return "contains whitespace" + prefix, separator, local_id = curie.partition(":") + if not separator: + return "no ':' separator" + if not prefix: + return "empty prefix" + if not local_id: + return "empty local identifier" + if ":" in local_id: + return "more than one ':'" + return None + + +def url(curie): + """A URL to look this CURIE up, or None if it is too malformed to try.""" + if malformed(curie): + return None + prefix, _, local_id = curie.partition(":") + uri_stem = PREFIX_MAP.get(prefix.upper()) + return uri_stem + local_id if uri_stem else BIOREGISTRY_URL.format(curie) + + +MALFORMED = "malformed" +UNKNOWN_PREFIX = "prefix unknown to the Biolink model" +NO_CLIQUE = "no Babel clique for this identifier" + +# Most actionable first: a malformed CURIE is a transform bug we can point at, an +# unknown prefix is a modelling gap, and the rest is ordinary Babel coverage. +PROBLEM_ORDER = {MALFORMED: 0, UNKNOWN_PREFIX: 1, NO_CLIQUE: 2} + + +def problem(curie): + """Why this CURIE plausibly failed to normalize. + + The prefix check is a signal, not a proven cause -- Babel decides coverage + for itself, not from the Biolink prefix map -- but a prefix the model has + never heard of is rarely a coincidence when its CURIEs all fail. + """ + reason = malformed(curie) + if reason: + return f"{MALFORMED}: {reason}" + if curie.partition(":")[0].upper() not in PREFIX_MAP: + return UNKNOWN_PREFIX + return NO_CLIQUE + + +def problem_rank(label): + return PROBLEM_ORDER.get(label.split(": ")[0], len(PROBLEM_ORDER)) + + +def stem(curie): + """The shape of a CURIE: everything up to its first digit. + + `PathBank:Reaction_13124` -> `PathBank:Reaction_`, `Ensembl:ENSRNOG00000019082` + -> `Ensembl:ENSRNOG`. Grouping on this splits pathbank's failures into + Reaction_, Compound_ and ProteinComplex_, and bgee's into one group per + species, which says far more than a flat list of 215,953 identifiers. + """ + prefix, _, local_id = curie.partition(":") + return prefix + ":" + re.match(r"[^0-9]*", local_id).group(0) + + +# Above this many groups, the stem is not finding real structure -- InChIKeys +# have no digits to split on, so 87 of them make 87 groups of one. +MAX_USEFUL_GROUPS = 12 + + +def group_by_stem(curies): + """[(stem, count, [curies in that group])], biggest group first. + + Returns None when grouping would not be informative, so the caller can fall + back to a plain list. + """ + counts = Counter(stem(curie) for curie in curies) + if len(counts) > MAX_USEFUL_GROUPS: + return None + grouped = {} + for curie in curies: + grouped.setdefault(stem(curie), []).append(curie) + return [ + (shape, counts[shape], grouped[shape]) + for shape, _ in counts.most_common() + ] + + +def as_markdown(curie, explain=False): + """One CURIE, monospaced so its exact characters are visible, linked if it can be. + + Monospacing matters here: it is how `UniProtKB:B3DHD6 Q6XCC7` reads as one + broken string rather than as ordinary prose. `explain` adds why a CURIE is + not linkable, which is too wide for a table cell but wanted in the listing. + """ + link = url(curie) + if link: + return f"[`{curie}`]({link})" + return f"`{curie}`" + (f" ⚠ {malformed(curie)}" if explain else " ⚠") diff --git a/src/normalization_dashboard/loader.py b/src/normalization_dashboard/loader.py new file mode 100644 index 0000000..af38da9 --- /dev/null +++ b/src/normalization_dashboard/loader.py @@ -0,0 +1,213 @@ +"""Load normalization metadata out of a local KGX Storage mirror. + +Pure data, no Dash imports, so a notebook or a static-JSON exporter can reuse it. +See CLAUDE.md for the mirror layout and what each file contains. +""" + +import json +from collections import defaultdict +from pathlib import Path + +MIRROR_NAME = Path("data/kgx-storage.ci.transltr.io") + + +def _default_mirror(): + """Find the mirror whether or not we were started from the repo root. + + An IDE run configuration or a `uv run --project` from elsewhere sets a + different working directory, which used to load zero rows in silence. + """ + from_repo = Path(__file__).parents[2] / MIRROR_NAME # src//loader.py -> repo + return next( + (path for path in (MIRROR_NAME, from_repo) if path.is_dir()), + from_repo if from_repo.parent.parent.is_dir() else MIRROR_NAME, + ) + + +DEFAULT_MIRROR = _default_mirror() + +# The mirror directory is named after the host, so the remote URL of any mirrored +# file is the host plus its path under the mirror root. +KGX_STORAGE_URL = "https://kgx-storage.ci.transltr.io/data" + + +def _normalized_to_str(normalized_to): + """'NCBIGene (91678); UniProtKB (82176)', biggest target first. + + The count is bracketed so a prefix and its count cannot be misread as a + CURIE: 'CHEBI 5' looked like a malformed one. + """ + return "; ".join( + f"{prefix} ({count})" + for prefix, count in sorted(normalized_to.items(), key=lambda kv: -kv[1]) + ) + + +def spread(values, limit): + """`limit` values spaced evenly across the list, not just its head. + + A prefix's failures are usually grouped in file order, so the first few are + often all alike; a spread shows whether the rest of them differ. + """ + if len(values) <= limit: + return list(values) + step = len(values) / limit + return [values[int(index * step)] for index in range(limit)] + + +def load_failure_examples(rows, limit=5): + """A few real unnormalized CURIEs per (source, uppercased prefix). + + One pass per failures file rather than per row -- 87 files, 3.2M lines, under + a second, holding only one file's CURIEs at a time. CURIEs keep the case the + file spells them with, which is half the point: seeing `Ensembl:ENSRNOG…` or + `PathBank:Reaction_13124` says more about why a prefix fails than the folded + prefix name does. + """ + paths = {} + for row in rows: + if row["failures_path"]: + paths.setdefault(row["failures_path"], (row["source"], row["is_latest"])) + + examples = {} + # Latest builds first, so a source's examples come from its current build. + for path, (source, _) in sorted(paths.items(), key=lambda item: not item[1][1]): + by_prefix = defaultdict(list) + with open(path) as failures: + for line in failures: + curie = line.strip() + if curie: + by_prefix[curie.split(":")[0].upper()].append(curie) + for prefix, curies in by_prefix.items(): + examples.setdefault( + (source, prefix), spread(list(dict.fromkeys(curies)), limit) + ) + return examples + + +def load_rows(mirror=DEFAULT_MIRROR): + """One row per (build, prefix), with the prefix spelled as the file spells it. + + `prefix_key` is the case-insensitive grouping key: NodeNorm resolves CURIE + prefixes case-insensitively, so ENSEMBL and Ensembl are the same prefix. + """ + mirror = Path(mirror) + if not mirror.is_dir(): + raise FileNotFoundError( + f"No KGX Storage mirror at {mirror.resolve()} -- run " + "./scripts/sync-kgx-normalization.sh from the repository root first." + ) + rows = [] + for metadata_path in sorted(mirror.glob("*/*/*/*/normalization-metadata.json")): + source, source_version, transform, normalization = metadata_path.relative_to( + mirror + ).parts[:4] + metadata = json.loads(metadata_path.read_text()) + failures_path = metadata_path.parent / "normalization_failures.txt" + for prefix, stats in metadata.get("normalization_by_prefix", {}).items(): + normalized_to = stats.get("normalized_to", {}) + rows.append( + { + "source": source, + "source_version": source_version, + "transform": transform.removeprefix("transform_"), + "normalization": normalization.removeprefix("normalization_"), + "babel_version": metadata.get("babel_version"), + "build_url": "/".join( + (KGX_STORAGE_URL, source, source_version, transform, normalization, "") + ), + "prefix": prefix, + "prefix_key": prefix.upper(), + "total": stats["total"], + "succeeded": stats["succeeded"], + "failed": stats["failed"], + # Recomputed, not taken from the file: the pipeline truncates + # success_rate (99.556 -> 99.55), which would not agree with the + # rates summarize() derives from pooled counts. + "success_rate": round(100 * stats["succeeded"] / stats["total"], 2) + if stats["total"] + else 0.0, + "normalized_to": normalized_to, + "normalized_to_str": _normalized_to_str(normalized_to), + "failures_path": str(failures_path) + if failures_path.exists() + else None, + } + ) + _mark_latest(rows, mirror) + return rows + + +def _mark_latest(rows, mirror): + """Flag the rows belonging to each source's current build. + + latest-build.json names the current transform; where it is missing (or names a + build we have not mirrored) fall back to the most recently modified build. + """ + builds = defaultdict(set) # source -> {(source_version, transform)} + for row in rows: + builds[row["source"]].add((row["source_version"], row["transform"])) + + latest = {} + for source, source_builds in builds.items(): + current = None + try: + build = json.loads((mirror / source / "latest-build.json").read_text()) + current = (build["source_version"], build["transform_version"]) + except (OSError, KeyError, json.JSONDecodeError): + pass + if current not in source_builds: + current = max( + source_builds, + key=lambda b: (mirror / source / b[0] / f"transform_{b[1]}").stat().st_mtime, + ) + latest[source] = current + + for row in rows: + row["is_latest"] = ( + latest[row["source"]] == (row["source_version"], row["transform"]) + ) + + +def summarize(rows): + """Pool rows by (source, case-insensitive prefix). + + Rates are recomputed from the summed counts rather than averaged, and the + spellings actually seen are reported so a case merge is never silent. + """ + grouped = defaultdict(list) + for row in rows: + grouped[(row["source"], row["prefix_key"])].append(row) + + summary = [] + for (source, prefix_key), group in grouped.items(): + total = sum(row["total"] for row in group) + succeeded = sum(row["succeeded"] for row in group) + normalized_to = defaultdict(int) + for row in group: + for target, count in row["normalized_to"].items(): + normalized_to[target.upper()] += count + # One link per build the row pools, pointing at the normalization output + # directory these numbers came from. + build_links = { + row["source_version"]: row["build_url"] + for row in sorted(group, key=lambda row: row["source_version"]) + } + summary.append( + { + "source": source, + "source_versions": ", ".join(build_links), + "source_versions_md": ", ".join( + f"[{version}]({url})" for version, url in build_links.items() + ), + "builds": len({(r["source_version"], r["transform"]) for r in group}), + "prefix": prefix_key, + "observed_as": ", ".join(sorted({row["prefix"] for row in group})), + "total": total, + "succeeded": succeeded, + "failed": total - succeeded, + "success_rate": round(100 * succeeded / total, 2) if total else 0.0, + "normalized_to_str": _normalized_to_str(normalized_to), + } + ) + return sorted(summary, key=lambda row: (row["success_rate"], -row["failed"])) diff --git a/tests/test_curie.py b/tests/test_curie.py new file mode 100644 index 0000000..12487b0 --- /dev/null +++ b/tests/test_curie.py @@ -0,0 +1,78 @@ +"""Checks for CURIE linking and malformed-CURIE detection. + +Run: uv run python tests/test_curie.py + +The malformed examples are real strings taken from normalization_failures.txt. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from normalization_dashboard import curie + + +def main(): + # Well formed, prefix in the Biolink map -> link straight to the source. + assert curie.malformed("MONDO:0001034") is None + assert curie.url("MONDO:0001034") == "http://purl.obolibrary.org/obo/MONDO_0001034" + # Prefix case does not matter, NodeNorm resolves prefixes case-insensitively. + assert curie.url("mondo:0001034") == curie.url("MONDO:0001034") + + # Well formed but not in the Biolink map -> Bioregistry resolves it anyway. + # PathBank is the largest such prefix: 215,953 unnormalized CURIEs. + assert curie.url("PathBank:SMP0000055") == "https://bioregistry.io/PathBank:SMP0000055" + + # Malformed: no link, and a reason that explains the normalization failure. + for value, reason in ( + ("rhea:RHEA:13065", "more than one ':'"), + ("CL:0000089 ∩ UBERON:0000473", "contains whitespace"), + ("UniProtKB:B3DHD6 Q6XCC7", "contains whitespace"), + ("UniProtKB:", "empty local identifier"), + ("MONDO_1034", "no ':' separator"), + (":0001034", "empty prefix"), + ): + assert curie.malformed(value) == reason, f"{value}: {curie.malformed(value)}" + assert curie.url(value) is None, value + assert reason in curie.as_markdown(value, explain=True) + # A table cell gets the warning sign but not the wide explanation. + assert curie.as_markdown(value) == f"`{value}` ⚠" + + # Linked, and monospaced so the exact characters of a CURIE are visible. + assert curie.as_markdown("MONDO:0001034").startswith("[`MONDO:0001034`](") + + # Grouping by problem, most actionable first. + assert curie.problem("rhea:RHEA:13065") == "malformed: more than one ':'" + assert curie.problem("PathBank:SMP0000055") == curie.UNKNOWN_PREFIX + assert curie.problem("MONDO:0001034") == curie.NO_CLIQUE + assert ( + curie.problem_rank(curie.problem("rhea:RHEA:13065")) + < curie.problem_rank(curie.UNKNOWN_PREFIX) + < curie.problem_rank(curie.NO_CLIQUE) + ) + + # Grouping on CURIE shape: everything up to the first digit. + assert curie.stem("PathBank:Reaction_13124") == "PathBank:Reaction_" + assert curie.stem("Ensembl:ENSRNOG00000019082") == "Ensembl:ENSRNOG" + assert curie.stem("FOODON:02021990") == "FOODON:" + + groups = curie.group_by_stem( + ["PathBank:Reaction_1", "PathBank:Reaction_2", "PathBank:Compound_9"] + ) + assert [(shape, count) for shape, count, _ in groups] == [ + ("PathBank:Reaction_", 2), + ("PathBank:Compound_", 1), + ], groups + + # InChIKeys have no digits to split on, so every one becomes its own group. + # Grouping declines rather than printing a list of groups of one. + inchikeys = [f"INCHIKEY:{chr(65 + n)}FOFVIBWSLOHFR-QDMKHBRRSA-N" for n in range(20)] + assert curie.group_by_stem(inchikeys) is None + + print(f"OK: {len(curie.PREFIX_MAP)} Biolink prefixes loaded") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_loader.py b/tests/test_loader.py new file mode 100644 index 0000000..b3be695 --- /dev/null +++ b/tests/test_loader.py @@ -0,0 +1,67 @@ +"""Invariants for the metadata loader. Run: uv run python tests/test_loader.py + +Checks against the real mirror rather than a fixture -- the failure mode worth +catching is a new DINGO build whose shape we did not expect. +""" + +import sys +from collections import Counter +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from normalization_dashboard.loader import DEFAULT_MIRROR, load_rows, spread, summarize + + +def main(): + # Examples are spread across a list, not taken from its head. + assert spread(list(range(10)), 5) == [0, 2, 4, 6, 8] + assert spread(["a", "b"], 5) == ["a", "b"] + assert spread([], 5) == [] + + if not DEFAULT_MIRROR.exists(): + print(f"SKIP: no mirror at {DEFAULT_MIRROR}; run scripts/sync-kgx-normalization.sh") + return 0 + + rows = load_rows() + assert rows, "no rows loaded" + + for row in rows: + where = f"{row['source']} {row['source_version']} {row['prefix']}" + assert row["succeeded"] + row["failed"] == row["total"], where + if row["total"]: + expected = round(100 * row["succeeded"] / row["total"], 2) + assert abs(row["success_rate"] - expected) <= 0.01, f"{where}: {row['success_rate']} != {expected}" + if row["normalized_to"]: + assert sum(row["normalized_to"].values()) == row["succeeded"], where + assert row["failures_path"] is None or Path(row["failures_path"]).exists(), where + + latest = {(row["source"], row["source_version"], row["transform"]) for row in rows if row["is_latest"]} + per_source = Counter(source for source, _, _ in latest) + assert all(count == 1 for count in per_source.values()), f"multiple latest builds: {per_source}" + assert per_source.keys() == {row["source"] for row in rows}, "a source has no latest build" + + summary = summarize(rows) + assert sum(row["total"] for row in summary) == sum(row["total"] for row in rows), "summarize lost rows" + keys = Counter((row["source"], row["prefix"]) for row in summary) + assert all(count == 1 for count in keys.values()), "summarize left duplicate (source, prefix)" + assert all(row["prefix"] == row["prefix"].upper() for row in summary), "summary prefix not case-folded" + + # Summary rows go straight into a DataTable, whose cells may only hold + # scalars -- a list or dict here makes the browser reject the whole table, + # which no server-side check would notice. + for row in summary: + for key, value in row.items(): + assert isinstance(value, (str, int, float, bool)), f"{key} is {type(value).__name__}" + + latest_summary = summarize([row for row in rows if row["is_latest"]]) + print( + f"OK: {len(rows)} rows over {len(per_source)} sources, " + f"{len({row['prefix_key'] for row in rows})} case-insensitive prefixes; " + f"{len(summary)} summary rows ({len(latest_summary)} latest-only)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e1ba457 --- /dev/null +++ b/uv.lock @@ -0,0 +1,570 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/31/4971872b3ed8715346231fb6eb4da8fcba65a4143c189db151ee28a2812b/charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e", size = 169295, upload-time = "2026-08-12T14:35:31.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/3c/045ea64ea5a550870dd8ab60b2242870328d53f17d2be593b4f9f3121474/charset_normalizer-3.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d", size = 343861, upload-time = "2026-08-12T14:32:27.254Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/7502be709db899d5b4801509829188b3a5a10969411da9c846115a5f1b70/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956", size = 237550, upload-time = "2026-08-12T14:32:28.385Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ce/66392661375148d9455c17bee25509a54e28c39969e34befa48ec8777936/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f", size = 229673, upload-time = "2026-08-12T14:32:29.668Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/f58c32a8d4ecf55b82ee61ee9aa6a664d4afcd36c72feb4c926fd6fe9af8/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046", size = 260768, upload-time = "2026-08-12T14:32:30.891Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ea/36c90e59a96386174377e855479ec154221ef001e96637e0b23be92489c4/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a", size = 257880, upload-time = "2026-08-12T14:32:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/23/35/5b85772eb82528ef22ba29487ad544a7049dfd27f35b1a5a55dbc0843048/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af", size = 247547, upload-time = "2026-08-12T14:32:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/b6/14/ba11a99c2a22ab04c2d5383a700b378cb463a78ab15f36444cabc10cd671/charset_normalizer-3.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3", size = 243326, upload-time = "2026-08-12T14:32:34.794Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/cadba3f2400b45aa1d62a4ae0298bf58a3b30b1158baf15c338c7ce5b601/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288", size = 238820, upload-time = "2026-08-12T14:32:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/41/d5188b9342d75b72c2b05d3ee373f01a691397e770f001ee05e3b37925f5/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0", size = 231661, upload-time = "2026-08-12T14:32:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/13/5f/df38fa972c4e945c3d8cee2bc4e610613af522fd359c7dc7a74c419f0278/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76", size = 261459, upload-time = "2026-08-12T14:32:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d7/043ff7720067a3beee05523465ac9c1c846c68b7884930dd483f72ee5ab6/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603", size = 242300, upload-time = "2026-08-12T14:32:39.505Z" }, + { url = "https://files.pythonhosted.org/packages/19/f6/33980b7b802a048e546a6d9ad2ea783a6cf6b10a86aaccd10db462d8b913/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b", size = 259101, upload-time = "2026-08-12T14:32:41.02Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b7/0d19bde844bff9165377c1da9ef3c4792a4c24bd49b5b7094d9e6f6ab58b/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9", size = 249246, upload-time = "2026-08-12T14:32:42.47Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cc/2c34fdfaacdf0e96e880ef562cbf80a9b5f8ea97e0dd9e57ba348a9c65cf/charset_normalizer-3.5.0-cp312-cp312-win32.whl", hash = "sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142", size = 178025, upload-time = "2026-08-12T14:32:43.909Z" }, + { url = "https://files.pythonhosted.org/packages/76/d0/c34dbd1df23bcbdc1b5d2f48256340d72fa747f1eb03924a9d2fa35ed85b/charset_normalizer-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14", size = 200143, upload-time = "2026-08-12T14:32:45.254Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/4e6bf1465d60c3d8f488d5bde140d0cb91cd23ccab0dc2895cc6c6982047/charset_normalizer-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6", size = 180046, upload-time = "2026-08-12T14:32:46.545Z" }, + { url = "https://files.pythonhosted.org/packages/1d/be/cc7b7b6fc41984902c0d31b06f5d9297e67705c1dae9352608e5540fad09/charset_normalizer-3.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144", size = 211050, upload-time = "2026-08-12T14:32:47.81Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ae/e3ec8f17313609f43f7b323012fdb1ee37b83432277ca4eceba83e00366c/charset_normalizer-3.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f", size = 222768, upload-time = "2026-08-12T14:32:49.027Z" }, + { url = "https://files.pythonhosted.org/packages/c5/50/9f9c0d7ccc1512d49e27a0e7c12c58ec71dfe91698fa4326f058c33e1f1b/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc", size = 193907, upload-time = "2026-08-12T14:32:50.414Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/cfe7b745ef7f4c3b7214581955b5a0869ba2ac551a58fc11036281ae167c/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991", size = 197135, upload-time = "2026-08-12T14:32:51.605Z" }, + { url = "https://files.pythonhosted.org/packages/28/55/30fafdcfca9ba616bc394240545e4cd52f4f66dea43ded81b7d2d5274fde/charset_normalizer-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64", size = 339892, upload-time = "2026-08-12T14:32:52.82Z" }, + { url = "https://files.pythonhosted.org/packages/f4/08/bdca5fc2bdc36ee443673dc7d12b23885a5a7b282bef85a1a4c3b325b40e/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b", size = 239439, upload-time = "2026-08-12T14:32:54.058Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9e/506c8d7a7722bba7c8cdd78c1b5ef23bda92bfbe0b3e28ea84673d519a0f/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee", size = 227896, upload-time = "2026-08-12T14:32:55.326Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/dd828f2b1d6f4bf10821b9a74d866be05ffcdbfcddfc501d6fe6428762a7/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d", size = 262548, upload-time = "2026-08-12T14:32:56.484Z" }, + { url = "https://files.pythonhosted.org/packages/be/81/196d26f6bd78b93e0d451b69082a71027ceeddd4b0be9170b81bb038f824/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f", size = 259986, upload-time = "2026-08-12T14:32:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/5b964a1eb0f9075ecd45083eeb21aaec215334f98bac3d400302ea73875d/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be", size = 249853, upload-time = "2026-08-12T14:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b4/aee3a9d82edd0e931091ef3e9f03e46491ae3590e96e998d0975dadbe17c/charset_normalizer-3.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9", size = 244217, upload-time = "2026-08-12T14:33:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/22/3e/33f72ca11c1b619b220fd9f35905ebd171cbd0e0470f2357e467b9e861ee/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715", size = 241307, upload-time = "2026-08-12T14:33:01.589Z" }, + { url = "https://files.pythonhosted.org/packages/78/27/6029dccba958621c7f3a65136f87c5512d712aef9e890f09512cc171bd03/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08", size = 233305, upload-time = "2026-08-12T14:33:02.866Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/691c967be459153fe9faf49bf78bc95639ef8bf6dd008f38cc6389a349eb/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2", size = 263465, upload-time = "2026-08-12T14:33:04.166Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2d/9202221be5c90b2a835924191e362690ed8dc8c7d6606100c2bd03fe0f8c/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d", size = 245060, upload-time = "2026-08-12T14:33:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9a/298772fd0a0cbccadf36451a1cd7eef4b66a11e99b4a7f6fafc47cc62c75/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d", size = 261091, upload-time = "2026-08-12T14:33:06.475Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/1a11fe66e555dbe2f5714ade6ba74fa29edc9155d9cf1001d4d6ed096aa7/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888", size = 251857, upload-time = "2026-08-12T14:33:07.784Z" }, + { url = "https://files.pythonhosted.org/packages/17/fc/73b817e8af3f1d25ec5cf458d405abba5a144cf9812238a61530f5eac186/charset_normalizer-3.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d", size = 139745, upload-time = "2026-08-12T14:33:09.123Z" }, + { url = "https://files.pythonhosted.org/packages/b3/fb/ddb66303c86f7dc5043a457dad9fa82b4d6d0cb97094f9bcdde21693fd58/charset_normalizer-3.5.0-cp313-cp313-win32.whl", hash = "sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e", size = 177217, upload-time = "2026-08-12T14:33:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/fb/88/6018cc8d76ea2b7cb02918f37e23e86c261d1a102713d7e88d2cfb8b211c/charset_normalizer-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a", size = 198896, upload-time = "2026-08-12T14:33:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/04c0bbfc8d9abf91959f7a3d207d45cbf63a8116984caae2381890019bb5/charset_normalizer-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b", size = 179193, upload-time = "2026-08-12T14:33:12.726Z" }, + { url = "https://files.pythonhosted.org/packages/43/14/d098868dac5ff27e0258f548b1c74c6484be528384965d8fcf8fc6a4011d/charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2", size = 211664, upload-time = "2026-08-12T14:33:14.153Z" }, + { url = "https://files.pythonhosted.org/packages/e7/da/a944b32a46601ae5a4c3499e8d64ecd14fe82313f00da74dcdf00273a0b4/charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636", size = 224375, upload-time = "2026-08-12T14:33:15.472Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/eabb5996be2f529744755e7b2fc9396eff4a64961f034e7fd49d54b9afb2/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b", size = 194364, upload-time = "2026-08-12T14:33:16.607Z" }, + { url = "https://files.pythonhosted.org/packages/78/65/4ad3c5be108930310d8003f5602861d5b89f728293b9f09c3a4837f7ba10/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45", size = 197643, upload-time = "2026-08-12T14:33:17.88Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8fee3201b98d52289be60a775797d69be05a04fb6cfb48c1587dad33e649/charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6", size = 341384, upload-time = "2026-08-12T14:33:19.239Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dd/9e757101d1f76c35c0643684ba499ac3a181fb2b264c68174bf727d627e8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73", size = 241637, upload-time = "2026-08-12T14:33:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/7857023015400bc4aa0a82fbcca29fa2dc7ec25f971a130764cb2dc7a589/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8", size = 226170, upload-time = "2026-08-12T14:33:21.773Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/8b52935b304f7b6bbf33151ed2b75266b09aa4b6f8f04230d948885b2577/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b", size = 265093, upload-time = "2026-08-12T14:33:22.999Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/6e838f6bb059f2c0afc60a4e7f294252f043c254656ad4114c50302cae4d/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd", size = 262789, upload-time = "2026-08-12T14:33:24.214Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/189b27e51fddc9d6b3695331da0e31792c1d88b953ad854e57f06e9b2cc8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c", size = 250580, upload-time = "2026-08-12T14:33:25.707Z" }, + { url = "https://files.pythonhosted.org/packages/ac/55/64854e99b25841f83e8e37d9df2f3d1f96f693439f80e5fabd542a7e47ab/charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458", size = 245008, upload-time = "2026-08-12T14:33:26.971Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/7720c904fa635d4260b4dced6029cf3d298c57b26741365d5a8d28c54043/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700", size = 243892, upload-time = "2026-08-12T14:33:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/70/50/7bfcb327631d4870c720872b548745f6ec8baa044d51c21b5d1d32ac4e3a/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a", size = 230996, upload-time = "2026-08-12T14:33:29.511Z" }, + { url = "https://files.pythonhosted.org/packages/24/51/40c45d6d940c04005ed721aa54bdebf1ebb2930f8a2ae537e8d60484fb27/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec", size = 265834, upload-time = "2026-08-12T14:33:30.689Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/ef7a227ef89d215b47f9df79c3966610b17faa13bb2f236989207a631622/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3", size = 245544, upload-time = "2026-08-12T14:33:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/37/a9/a4ca9156964ded61c7718eba410ce11be2fd2b263fda4bcf08367b6578cd/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0", size = 264110, upload-time = "2026-08-12T14:33:33.13Z" }, + { url = "https://files.pythonhosted.org/packages/38/6a/838364bb8702229c6e5f8b23f80ff0f052a12dfaf3113a12fd6acbe92a44/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb", size = 252303, upload-time = "2026-08-12T14:33:34.98Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5f/d88032edce951f499a2321cf7ae0d35a043c74be12bc22d81084cc7afbcc/charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053", size = 139964, upload-time = "2026-08-12T14:33:36.195Z" }, + { url = "https://files.pythonhosted.org/packages/37/ae/1c4a46b6b00d1c34d2ee355ef99ad6173674166800d1af0f05f85028d513/charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e", size = 179790, upload-time = "2026-08-12T14:33:37.356Z" }, + { url = "https://files.pythonhosted.org/packages/01/51/f94dcf34fa8eba48c1fb89b6490a5f1426e19488fe5f38aac6c648c99057/charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4", size = 203723, upload-time = "2026-08-12T14:33:38.639Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ba/91d386870b5d9e4b0d8c4034f63877cc2e47b99c81ef05f3e6d42bf9a53f/charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834", size = 183423, upload-time = "2026-08-12T14:33:39.899Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c9/534ecb17b7fb95f9052c4a44cf316316a27d4a8f73e8475ff55e778dcdd7/charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6", size = 368967, upload-time = "2026-08-12T14:33:41.093Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/ad7c3242d7fe55cd55126c22c65cb1b49779782cdf8932fd01d12232d86a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f", size = 239478, upload-time = "2026-08-12T14:33:42.428Z" }, + { url = "https://files.pythonhosted.org/packages/7e/62/77f0b850048e430fc350ec58876b0c020f5c8d0d3956fd1a4d6ae2fa292f/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1", size = 227036, upload-time = "2026-08-12T14:33:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ba/47d951e1a51dddbaad0a1410baf49fb1d897ceb00281568f1183b79bce9a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa", size = 260772, upload-time = "2026-08-12T14:33:44.96Z" }, + { url = "https://files.pythonhosted.org/packages/b0/61/8c7ff4c81b2a88271126acf4b83ab3e31f6d63868b0f01d331eaa0f9cb67/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4", size = 259273, upload-time = "2026-08-12T14:33:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fd/36129689be08dc287b951306946657ff70d76e287dd57018861f86d0e474/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6", size = 248086, upload-time = "2026-08-12T14:33:47.54Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f8/bcae67f994c8fd31dda445e5ebf84045823c31443fe46f0e9ee6aca99aa0/charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0", size = 242671, upload-time = "2026-08-12T14:33:48.746Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/0472cdad1061c2f0e4d3aee29973eb6e81bb8fe256ff2860cf115b15f1c9/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0", size = 241311, upload-time = "2026-08-12T14:33:50.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/89/04a03de5d27c77c624d9fcf6287073754bd438df1b58cb7d030c57c2824d/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19", size = 229898, upload-time = "2026-08-12T14:33:51.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/a2/639c4278adcb7ed1f4db608dd9ac19b6774fa2285a96b1c0bdb9c124ccbd/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50", size = 262852, upload-time = "2026-08-12T14:33:52.924Z" }, + { url = "https://files.pythonhosted.org/packages/9d/95/02e34c97bedfd0c5574efb9179c850591acc7f967ba039ed8dd29d332b73/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa", size = 242913, upload-time = "2026-08-12T14:33:54.18Z" }, + { url = "https://files.pythonhosted.org/packages/a3/64/0946aeab6462dad9f160a50dfb4704d3f58a5ee708f085abc2105fbbff0c/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9", size = 257938, upload-time = "2026-08-12T14:33:55.802Z" }, + { url = "https://files.pythonhosted.org/packages/79/77/36787d41ead124746506a4425c729f4f17c68280af8a6a5baa0a598cae86/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40", size = 249467, upload-time = "2026-08-12T14:33:57.081Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/d9f6c5589cd24198d4ce6cd2948191c18e657272f433e5a00d258d9f5c22/charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6", size = 190624, upload-time = "2026-08-12T14:33:58.449Z" }, + { url = "https://files.pythonhosted.org/packages/6c/81/43e0584a802051a22c725795ebe1df78263abc7de858eef6cdc9b36637e9/charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3", size = 215902, upload-time = "2026-08-12T14:33:59.753Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/af6a1160fef0eac4510d035241e11eccf78e5350e4cd4de79e79fe02a5e5/charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897", size = 193452, upload-time = "2026-08-12T14:34:01.017Z" }, + { url = "https://files.pythonhosted.org/packages/42/a4/dee470afb7a55c4f78b6fef37306c51fed17ebf94dbe530798c91d394350/charset_normalizer-3.5.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce", size = 341595, upload-time = "2026-08-12T14:34:02.4Z" }, + { url = "https://files.pythonhosted.org/packages/6a/32/9c3126dc429c6d9d7f79c52681a7c4453ed20a26267c9a8275d7ab620aba/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757", size = 242177, upload-time = "2026-08-12T14:34:03.741Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9d/5b616a887301ff4cc0916b39ba44257390d3da80deeed6e8b6f2f26b14a8/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396", size = 236730, upload-time = "2026-08-12T14:34:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/d6d3e70be93ebe5fabef65e4c7ac113e1d1705cbaeb5fb72467e713aca17/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44", size = 265158, upload-time = "2026-08-12T14:34:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/3f1fafa87e2643257474f9c4eec609f2193a61d907dce7dd4f3f2390ebd5/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293", size = 262931, upload-time = "2026-08-12T14:34:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/0a/df/ebeb224a949d91829e5e114c6b64372a3c792b00762a9e951ce416f3a32d/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85", size = 251388, upload-time = "2026-08-12T14:34:08.949Z" }, + { url = "https://files.pythonhosted.org/packages/a0/64/9a6ce2e7acc5cf1b4636f78f82e89ff581e06a0216a40678b28bd4d832c4/charset_normalizer-3.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1", size = 251821, upload-time = "2026-08-12T14:34:10.138Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b1/6e69b8056f615e5ccff6b91ca16db2d47922251f016821a300c115267fef/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78", size = 244507, upload-time = "2026-08-12T14:34:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/0f/34/02c15d6a0aa6b934dcdc136b111da63ae857b9fd51cf5505b0736337c2eb/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b", size = 240951, upload-time = "2026-08-12T14:34:12.991Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/0fe893d3e1c7d111280bd6c4bd4c1e431487a1124a1bcbce78dfeda3a3a8/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f", size = 266162, upload-time = "2026-08-12T14:34:14.232Z" }, + { url = "https://files.pythonhosted.org/packages/55/ea/eca03527307670f5d102c295671a800c404ca958cf94fefd10fc963a72f0/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80", size = 251835, upload-time = "2026-08-12T14:34:15.48Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/fee5633081e595fe9e191df6f215106c791ad596eddf5e41e39b8ea0f2e2/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9", size = 264314, upload-time = "2026-08-12T14:34:16.679Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fb/17f47ae6ca35b562fb6e6f4b05f7aec6034217353eb4a23aaa3566dc7340/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4", size = 253194, upload-time = "2026-08-12T14:34:17.975Z" }, + { url = "https://files.pythonhosted.org/packages/59/88/f2b0f7ebb92493e925889ff29239b3b0073ffafd91230dbfc69e5cf9389c/charset_normalizer-3.5.0-cp315-cp315-win32.whl", hash = "sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731", size = 179800, upload-time = "2026-08-12T14:34:19.409Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f0/afb5bfdea52fd943b1960403847a276b8e900c6e4cd6a38752321b4eda64/charset_normalizer-3.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040", size = 203726, upload-time = "2026-08-12T14:34:20.656Z" }, + { url = "https://files.pythonhosted.org/packages/fc/71/219783eb691aa2ec879c0e521afdfe2b826f9678eed51b9c039d03e0db2b/charset_normalizer-3.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e", size = 183428, upload-time = "2026-08-12T14:34:21.975Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/14aef3b9f80f2593c039d897e89034635b9eb0eb44b6ce5173bbd79ff338/charset_normalizer-3.5.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a", size = 368728, upload-time = "2026-08-12T14:34:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/10/fc/b249466ddbbeffa448b6597631e9091d1f01b5132ff8e7a0e21a6eb72b63/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698", size = 240925, upload-time = "2026-08-12T14:34:24.504Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b9/c17e72aaa1b3e1ca6c184e8025cf138ed492d01a54f85286ff7d31253a4b/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075", size = 234932, upload-time = "2026-08-12T14:34:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/f84ef0966bbe216f71029e34e7fa425a16b1682e2a40265e679dedf2b655/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74", size = 261733, upload-time = "2026-08-12T14:34:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/3b/73/3e887fa0781a395339355ed934ab6561ceb5bb52574160f070224039c630/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4", size = 258460, upload-time = "2026-08-12T14:34:28.431Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/52ebd9849bf9e35d0b21fff115cb6543162a8e1f2f564e8f87121a336b8c/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258", size = 249894, upload-time = "2026-08-12T14:34:29.698Z" }, + { url = "https://files.pythonhosted.org/packages/85/f3/9366492b8a5fe0187de282e001d61345740cf79eb4a5f20181d769be02b5/charset_normalizer-3.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4", size = 249540, upload-time = "2026-08-12T14:34:30.96Z" }, + { url = "https://files.pythonhosted.org/packages/0b/af/28bb5e5dbd3e67cb9196a62781ac2b6d79492f4fc7a069b6ca7d6d6c8d58/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2", size = 242734, upload-time = "2026-08-12T14:34:32.481Z" }, + { url = "https://files.pythonhosted.org/packages/26/d6/7ccfa62b53b40fc06b2d3504825aa400764740bd10cf248fdc4272441b93/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d", size = 239580, upload-time = "2026-08-12T14:34:33.916Z" }, + { url = "https://files.pythonhosted.org/packages/0b/82/71c0c9b046697b8da66b3acefa8d5f92d00a9ef433ad7c3522b971d0369a/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e", size = 263281, upload-time = "2026-08-12T14:34:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/d6/01/d027583c869f40ba980c1c76994adbd522c360a6327e72beb44d7c267385/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394", size = 250027, upload-time = "2026-08-12T14:34:36.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e9/6475d739e0ec8bb1236e06263dc3affaffdf947d8114ad27024932f325da/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0", size = 257547, upload-time = "2026-08-12T14:34:38.277Z" }, + { url = "https://files.pythonhosted.org/packages/a5/60/d1f502fcaa048a2aca3ab80bfef8407659c131e4f1792fa805fec14b4960/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97", size = 251718, upload-time = "2026-08-12T14:34:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/76343dcf4381a698807ff8a20d89f66bbdd9f6222b0b17740f77ab764335/charset_normalizer-3.5.0-cp315-cp315t-win32.whl", hash = "sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7", size = 190757, upload-time = "2026-08-12T14:34:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ea/d18147626a1667cc773c42104ab155a4ca5d6d4d174b7a35e01062213ea5/charset_normalizer-3.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775", size = 215431, upload-time = "2026-08-12T14:34:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/47/21/4869598aae0872d94faa5933918a4fe37ab2c5af9d095786e241f9506fed/charset_normalizer-3.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03", size = 193205, upload-time = "2026-08-12T14:34:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f3/7b523d807cb5e73562ef8acf21d39cdb9d704955327362c781bc3478a73d/charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5", size = 330840, upload-time = "2026-08-12T14:34:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/f0/de/fc68978fe78ca97063c96d764e41ff92ca639948f319271e0ff450e577a2/charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3", size = 251862, upload-time = "2026-08-12T14:34:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cb/82b41a0ab7fb1a88065f1d78ad32696ad88ea3fe8e25b8189d08833938de/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3", size = 239484, upload-time = "2026-08-12T14:34:47.869Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0c/19608b631f4538f908098d4a2d56a8f79a665e27cc58e9d90479761a9227/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438", size = 230602, upload-time = "2026-08-12T14:34:49.265Z" }, + { url = "https://files.pythonhosted.org/packages/29/db/f648eb30e14eba301aed61e11672156f137905c1bdbb530151abe8065943/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a", size = 259208, upload-time = "2026-08-12T14:34:50.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e0/ed2c8bdbac484d69614d6993143aeb6cb0f4dd1561c883402517b623c8ef/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539", size = 253659, upload-time = "2026-08-12T14:34:52.11Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/b4907cb9ec5b521d9d024ced13611240b86ef065c2eb15b3ad2334dc9940/charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706", size = 248821, upload-time = "2026-08-12T14:34:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/12/b2/e2d1abcfbc05822f0030869efb4e9f8a3658e13b4821796d4b62da917327/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26", size = 240271, upload-time = "2026-08-12T14:34:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f9/4ba127ad610542fa3eabfa41c45bf12d357860a815b3566374ec0188e213/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3", size = 232155, upload-time = "2026-08-12T14:34:56.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/68/40613182366d00bd6dbd5f6c84a926cbd120960e038a8269e9ae7d782762/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e", size = 259674, upload-time = "2026-08-12T14:34:57.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/53/4574a14fa4c9de4a6c9f31725354bfa40b67f653e6d594ce1654f9a41b32/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49", size = 246122, upload-time = "2026-08-12T14:34:59.337Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/bd8030d13d92c058ca7b2b9615bbb3169569e144db64d65c149cd45abf5e/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45", size = 255221, upload-time = "2026-08-12T14:35:00.71Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/10ecd3bcbe2666b3d4d4026c97b48f73990682815db516052a1e8f4a31c5/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8", size = 253450, upload-time = "2026-08-12T14:35:02.217Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/d044c4872c0938a84f87b5027a698c0e61bacfc5c3551a4e749ca9b7bc5c/charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516", size = 173594, upload-time = "2026-08-12T14:35:03.845Z" }, + { url = "https://files.pythonhosted.org/packages/10/6b/6046773901f1944b9a89436351529811ee958afc7b774563be9d74a6f0c3/charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74", size = 198959, upload-time = "2026-08-12T14:35:05.187Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/b57708ac92aefc8e8389d51d5178129b81f03196da61ee2c23e687b8178a/charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca", size = 267055, upload-time = "2026-08-12T14:35:06.533Z" }, + { url = "https://files.pythonhosted.org/packages/22/c7/754d09943a616937df61e4ba367c409ded2a987e872972098d51a6fcf73b/charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea", size = 67943, upload-time = "2026-08-12T14:35:30.363Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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 = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "dash" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "flask" }, + { name = "importlib-metadata" }, + { name = "janus" }, + { name = "nest-asyncio" }, + { name = "plotly" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "retrying" }, + { name = "setuptools" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/67/d5cea6dedfbfde4165436fb58154480ea6358a9e71a739d3366575b5b724/dash-4.4.1.tar.gz", hash = "sha256:9356ca7856bc496c12c5b6de5978aaf783e090d07450ee395ade1c0c2cbdf816", size = 8536482, upload-time = "2026-07-21T18:58:19.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/29/498125538103a11eab9cfbbde41bacf93e14ab3ac92ae9fb4ac0ac4dd61b/dash-4.4.1-py3-none-any.whl", hash = "sha256:72120a91b10ee4d73f9446efd5d6a4ec218086feed7b1b479d2259844d1f658f", size = 8945027, upload-time = "2026-07-21T18:58:11.405Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "janus" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/7f/69884b6618be4baf6ebcacc716ee8680a842428a19f403db6d1c0bb990aa/janus-2.0.0.tar.gz", hash = "sha256:0970f38e0e725400496c834a368a67ee551dc3b5ad0a257e132f5b46f2e77770", size = 22910, upload-time = "2024-12-13T12:59:08.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/34/65604740edcb20e1bda6a890348ed7d282e7dd23aa00401cbe36fd0edbd9/janus-2.0.0-py3-none-any.whl", hash = "sha256:7e6449d34eab04cd016befbd7d8c0d8acaaaab67cb59e076a69149f9031745f9", size = 12161, upload-time = "2024-12-13T12:59:06.106Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "narwhals" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "normalization-dashboard" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "dash" }, +] + +[package.metadata] +requires-dist = [{ name = "dash", specifier = ">=4.4.1" }] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "plotly" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/07/795c79dbce40c39bece88e69d049babbd23ffa95b5d117f248db8ea03abb/plotly-6.9.0.tar.gz", hash = "sha256:967ad33e8c704fed051800d11d985eb206a9c795c14206b30a6f463ed9c67d0d", size = 6919903, upload-time = "2026-07-09T14:55:59.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/18/d8544811ab076f876c4892b3714f5b0dad335e1dc33aef826df431b8325d/plotly-6.9.0-py3-none-any.whl", hash = "sha256:36bebe2f1bb13884774fe61689c329071446f6ce4a8927fb1f0d6fb24f581236", size = 9909646, upload-time = "2026-07-09T14:55:55.421Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "retrying" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]