From 684ff093e541b55ca0705bad475f047ca7d8958d Mon Sep 17 00:00:00 2001 From: hill Date: Mon, 27 Oct 2025 16:03:51 -0400 Subject: [PATCH 01/13] Add DuckDB as an engine to init CLI command. DuckDB becomes useful when we want to query not just the small demo dataset locally, but the full MIMIC-IV dataset. --- pyproject.toml | 1 + src/m3/cli.py | 66 ++++++++------------------- src/m3/config.py | 22 ++++++--- src/m3/data_io.py | 114 +++++++++++++++++++++++++++++++++++++++++++++- uv.lock | 36 ++++++++++++++- 5 files changed, 184 insertions(+), 55 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 66269dc..fb71657 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "cryptography>=41.0.0", # Cryptographic operations for JWT "python-jose[cryptography]>=3.3.0", # Additional JWT support with crypto "httpx>=0.24.0", # Modern HTTP client for OAuth2 token validation + "duckdb>=1.4.1", ] [project.dependency-groups] diff --git a/src/m3/cli.py b/src/m3/cli.py index 9198dfa..7450e74 100644 --- a/src/m3/cli.py +++ b/src/m3/cli.py @@ -15,7 +15,7 @@ get_default_database_path, logger, ) -from m3.data_io import initialize_dataset +from m3.data_io import initialize_dataset, build_duckdb_from_existing_raw, verify_table_rowcount app = typer.Typer( name="m3", @@ -86,6 +86,10 @@ def dataset_init_cmd( help="Custom path for the SQLite DB. Uses a default if not set.", ), ] = None, + engine: Annotated[ + str, + typer.Option("--engine", "-e", help="Engine to use (sqlite or duckdb). Default: sqlite"), + ] = "sqlite", ): """ Download a supported dataset (e.g., 'mimic-iv-demo') and load it into a local SQLite @@ -126,7 +130,7 @@ def dataset_init_cmd( final_db_path = ( Path(db_path_str).resolve() if db_path_str - else get_default_database_path(dataset_key) + else get_default_database_path(dataset_key, engine) ) if not final_db_path: typer.secho( @@ -146,9 +150,16 @@ def dataset_init_cmd( typer.echo(f"Target database path: {final_db_path}") typer.echo(f"Raw files will be stored at: {raw_files_storage_path.resolve()}") - initialization_successful = initialize_dataset( - dataset_name=dataset_key, db_target_path=final_db_path - ) + + if engine == "sqlite": # Default + initialization_successful = initialize_dataset( + dataset_name=dataset_key, db_target_path=final_db_path + ) + else: # duckdb + typer.echo("Converting existing CSVs to Parquet and creating DuckDB views...") + initialization_successful = build_duckdb_from_existing_raw( + dataset_name=dataset_key, db_target_path=final_db_path + ) if not initialization_successful: typer.secho( @@ -187,53 +198,16 @@ def dataset_init_cmd( return try: - conn = sqlite3.connect(final_db_path) - cursor = conn.cursor() - # A simple count query is usually safe and informative. - query = f"SELECT COUNT(*) FROM {verification_table_name};" - logger.debug(f"Executing verification query: '{query}' on {final_db_path}") - cursor.execute(query) - count_result = cursor.fetchone() - conn.close() - - if count_result is None: - raise sqlite3.Error( - f"Query on table '{verification_table_name}' returned no result (None)." - ) - - record_count = count_result[0] + record_count = verify_table_rowcount(engine, final_db_path, verification_table_name) typer.secho( - ( - f"Database verification successful: Found {record_count} records in " - f"table '{verification_table_name}'." - ), + f"Database verification successful: Found {record_count} records in table '{verification_table_name}'.", fg=typer.colors.GREEN, ) typer.secho( - ( - f"Dataset '{dataset_name}' ready at {final_db_path}. " - f"Raw files at {raw_files_storage_path.resolve()}." - ), + f"Dataset '{dataset_name}' ready at {final_db_path}. Raw files at {raw_files_storage_path.resolve()}.", fg=typer.colors.BRIGHT_GREEN, ) - except sqlite3.Error as e: - logger.error( - ( - f"SQLite error during verification query on table " - f"'{verification_table_name}': {e}" - ), - exc_info=True, - ) - typer.secho( - ( - f"Error verifying table '{verification_table_name}': {e}. " - f"The database was created at {final_db_path}, but the test query " - "failed. The data might be incomplete or corrupted." - ), - fg=typer.colors.RED, - err=True, - ) - except Exception as e: # Catch any other unexpected errors + except Exception as e: logger.error( f"Unexpected error during database verification: {e}", exc_info=True ) diff --git a/src/m3/config.py b/src/m3/config.py index eaa7e51..666283b 100644 --- a/src/m3/config.py +++ b/src/m3/config.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +from typing import Literal APP_NAME = "m3" @@ -46,6 +47,7 @@ def _get_project_root() -> Path: "file_listing_url": "https://physionet.org/files/mimic-iv-demo/2.2/", "subdirectories_to_scan": ["hosp", "icu"], "default_db_filename": "mimic_iv_demo.db", + "default_duckdb_filename": "mimic_iv_demo.duckdb", "primary_verification_table": "hosp_admissions", # Table name in SQLite DB }, # add other datasets here... @@ -60,19 +62,25 @@ def get_dataset_config(dataset_name: str) -> dict | None: return SUPPORTED_DATASETS.get(dataset_name.lower()) -def get_default_database_path(dataset_name: str) -> Path | None: +def get_default_database_path(dataset_name: str, engine: Literal["sqlite", "duckdb"] = "sqlite") -> Path | None: """ - Return the default SQLite DB path for a given dataset, + Return the default local DB path for a given dataset and engine, under /m3_data/databases/. """ cfg = get_dataset_config(dataset_name) - if cfg and "default_db_filename" in cfg: - DEFAULT_DATABASES_DIR.mkdir(parents=True, exist_ok=True) - return DEFAULT_DATABASES_DIR / cfg["default_db_filename"] - logger.warning(f"Missing default_db_filename for dataset: {dataset_name}") - return None + if not cfg: + logger.warning(f"Unknown dataset, cannot determine default DB path: {dataset_name}") + return None + + DEFAULT_DATABASES_DIR.mkdir(parents=True, exist_ok=True) + db_fname = cfg.get("default_db_filename") if engine == "sqlite" else cfg.get("default_duckdb_filename") + if not db_fname: + logger.warning(f"Missing default filename for dataset: {dataset_name}") + return None + + return DEFAULT_DATABASES_DIR / db_fname def get_dataset_raw_files_path(dataset_name: str) -> Path | None: """ diff --git a/src/m3/data_io.py b/src/m3/data_io.py index dffb973..9b966cb 100644 --- a/src/m3/data_io.py +++ b/src/m3/data_io.py @@ -1,10 +1,11 @@ from pathlib import Path from urllib.parse import urljoin, urlparse - +from typing import Literal import polars as pl import requests import typer from bs4 import BeautifulSoup +import duckdb from m3.config import get_dataset_config, get_dataset_raw_files_path, logger @@ -332,3 +333,114 @@ def initialize_dataset(dataset_name: str, db_target_path: Path) -> bool: f"Database at: {db_target_path}" ) return True + + +######################################################## +# DuckDB functions +######################################################## + +def _csv_to_parquet_all(src_root: Path, parquet_root: Path) -> bool: + """ + Convert all CSV files in the source directory to Parquet files. + """ + parquet_paths: list[Path] = [] + for csv_gz in src_root.rglob("*.csv.gz"): + rel = csv_gz.relative_to(src_root) + out = parquet_root / rel.with_suffix("").with_suffix(".parquet") + out.parent.mkdir(parents=True, exist_ok=True) + try: + df = pl.read_csv( + source=str(csv_gz), + infer_schema_length=None, + try_parse_dates=True, + ignore_errors=False, + null_values=["", "NULL", "null", "\\N", "NA"], + ) + df.write_parquet(str(out)) + parquet_paths.append(out) + except Exception as e: + logger.error(f"Parquet conversion failed for {csv_gz}: {e}") + return False + logger.info(f"Converted {len(parquet_paths)} files to Parquet under {parquet_root}") + return True + +def _create_duckdb_with_views(db_path: Path, parquet_root: Path) -> bool: + """ + Create a DuckDB database from existing Parquet files and create views for each table. + """ + con = duckdb.connect(str(db_path)) + try: + for pq in parquet_root.rglob("*.parquet"): + rel = pq.relative_to(parquet_root) + parts = [p.lower() for p in rel.parts] + table = ( + "_".join(parts) + .replace(".parquet", "") + .replace("-", "_") + .replace(".", "_") + ) + sql = ( + f"CREATE OR REPLACE VIEW {table} " + f"AS SELECT * FROM read_parquet('{pq.as_posix()}');" + ) + con.execute(sql) + con.commit() + return True + finally: + con.close() + +def build_duckdb_from_existing_raw(dataset_name: str, db_target_path: Path) -> bool: + """ + Build a DuckDB database from existing raw CSVs (no downloads). + - Converts CSVs under m3_data/raw_files// to Parquet + - Creates views in DuckDB that point to those Parquet files + """ + raw_files_root_dir = get_dataset_raw_files_path(dataset_name) + if not raw_files_root_dir or not raw_files_root_dir.exists(): + logger.error( + f"Raw files directory not found for dataset '{dataset_name}'. " + "Run 'm3 init mimic-iv-demo' first for the demo, or place full data under " + f"{(raw_files_root_dir or Path()).resolve()}." + ) + return False + + parquet_root = raw_files_root_dir.parent / "parquet" / dataset_name + parquet_root.mkdir(parents=True, exist_ok=True) + + if not _csv_to_parquet_all(raw_files_root_dir, parquet_root): + return False + + return _create_duckdb_with_views(db_target_path, parquet_root) + + +######################################################## +# Verification functions +######################################################## + +def verify_table_rowcount( + engine: Literal["sqlite", "duckdb"], + db_path: Path, + table_name: str, +) -> int: + if engine == "sqlite": + import sqlite3 + conn = sqlite3.connect(db_path) + try: + cur = conn.cursor() + cur.execute(f"SELECT COUNT(*) FROM {table_name}") + row = cur.fetchone() + if row is None: + raise sqlite3.Error("No result") + return int(row[0]) + finally: + conn.close() + else: # duckdb + import duckdb + con = duckdb.connect(str(db_path)) + try: + row = con.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone() + if row is None: + raise RuntimeError("No result") + return int(row[0]) + finally: + con.close() diff --git a/uv.lock b/uv.lock index e2af58c..f2451ff 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13'", @@ -304,6 +304,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, ] +[[package]] +name = "duckdb" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/e7/21cf50a3d52ffceee1f0bcc3997fa96a5062e6bab705baee4f6c4e33cce5/duckdb-1.4.1.tar.gz", hash = "sha256:f903882f045d057ebccad12ac69975952832edfe133697694854bb784b8d6c76", size = 18461687, upload-time = "2025-10-07T10:37:28.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/cc/00a07de0e33d16763edd4132d7c8a2f9efd57a2f296a25a948f239a1fadf/duckdb-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:296b4fff3908fb4c47b0aa1d77bd1933375e75401009d2dc81af8e7a0b8a05b4", size = 29062814, upload-time = "2025-10-07T10:36:14.261Z" }, + { url = "https://files.pythonhosted.org/packages/17/ea/fb0fda8886d1928f1b2a53a1163ef94f6f4b41f6d8b29eee457acfc2fa67/duckdb-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b4182800092115feee5d71a8691efb283d3c9f5eb0b36362b308ef007a12222", size = 16161652, upload-time = "2025-10-07T10:36:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/052e6436a71f461e61cd3a982954c029145a84b58cefa1dfb3eb2d96e4fc/duckdb-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:67cc3b6c7f7ba07a69e9331b8ccea7a60cbcd4204bb473e5da9b71588bd2eca9", size = 13753030, upload-time = "2025-10-07T10:36:19.782Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fd/3ae3c89d0f6ad54c0be4430e572306fbfc9f173c97b23c5025a540449325/duckdb-1.4.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cef0cee7030b561640cb9af718f8841b19cdd2aa020d53561057b5743bea90b", size = 18487683, upload-time = "2025-10-07T10:36:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/eef454cd7c3880c2d55b50e18a9c7a213bf91ded79efcfb573d8d6dd8a47/duckdb-1.4.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bf93347f37a46bacce6ac859d651dbf5731e2c94a64ab358300425b09e3de23", size = 20487080, upload-time = "2025-10-07T10:36:24.692Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5b/b619f4c986a1cb0b06315239da9ce5fd94a20c07a344d03e2635d56a6967/duckdb-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:2e60d2361f978908a3d96eebaf1f4b346f283afcc467351aae50ea45ca293a2b", size = 12324436, upload-time = "2025-10-07T10:36:27.458Z" }, + { url = "https://files.pythonhosted.org/packages/d9/52/606f13fa9669a24166d2fe523e28982d8ef9039874b4de774255c7806d1f/duckdb-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:605d563c1d5203ca992497cd33fb386ac3d533deca970f9dcf539f62a34e22a9", size = 29065894, upload-time = "2025-10-07T10:36:29.837Z" }, + { url = "https://files.pythonhosted.org/packages/84/57/138241952ece868b9577e607858466315bed1739e1fbb47205df4dfdfd88/duckdb-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d3305c7c4b70336171de7adfdb50431f23671c000f11839b580c4201d9ce6ef5", size = 16163720, upload-time = "2025-10-07T10:36:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/81/afa3a0a78498a6f4acfea75c48a70c5082032d9ac87822713d7c2d164af1/duckdb-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a063d6febbe34b32f1ad2e68822db4d0e4b1102036f49aaeeb22b844427a75df", size = 13756223, upload-time = "2025-10-07T10:36:34.673Z" }, + { url = "https://files.pythonhosted.org/packages/47/dd/5f6064fbd9248e37a3e806a244f81e0390ab8f989d231b584fb954f257fc/duckdb-1.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1ffcaaf74f7d1df3684b54685cbf8d3ce732781c541def8e1ced304859733ae", size = 18487022, upload-time = "2025-10-07T10:36:36.759Z" }, + { url = "https://files.pythonhosted.org/packages/a1/10/b54969a1c42fd9344ad39228d671faceb8aa9f144b67cd9531a63551757f/duckdb-1.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685d3d1599dc08160e0fa0cf09e93ac4ff8b8ed399cb69f8b5391cd46b5b207c", size = 20491004, upload-time = "2025-10-07T10:36:39.318Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d5/7332ae8f804869a4e895937821b776199a283f8d9fc775fd3ae5a0558099/duckdb-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:78f1d28a15ae73bd449c43f80233732adffa49be1840a32de8f1a6bb5b286764", size = 12327619, upload-time = "2025-10-07T10:36:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6c/906a3fe41cd247b5638866fc1245226b528de196588802d4df4df1e6e819/duckdb-1.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cd1765a7d180b7482874586859fc23bc9969d7d6c96ced83b245e6c6f49cde7f", size = 29076820, upload-time = "2025-10-07T10:36:43.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c7/01dd33083f01f618c2a29f6dd068baf16945b8cbdb132929d3766610bbbb/duckdb-1.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8ed7a86725185470953410823762956606693c0813bb64e09c7d44dbd9253a64", size = 16167558, upload-time = "2025-10-07T10:36:46.003Z" }, + { url = "https://files.pythonhosted.org/packages/81/e2/f983b4b7ae1dfbdd2792dd31dee9a0d35f88554452cbfc6c9d65e22fdfa9/duckdb-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8a189bdfc64cfb9cc1adfbe4f2dcfde0a4992ec08505ad8ce33c886e4813f0bf", size = 13762226, upload-time = "2025-10-07T10:36:48.55Z" }, + { url = "https://files.pythonhosted.org/packages/ed/34/fb69a7be19b90f573b3cc890961be7b11870b77514769655657514f10a98/duckdb-1.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9090089b6486f7319c92acdeed8acda022d4374032d78a465956f50fc52fabf", size = 18500901, upload-time = "2025-10-07T10:36:52.445Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a5/1395d7b49d5589e85da9a9d7ffd8b50364c9d159c2807bef72d547f0ad1e/duckdb-1.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:142552ea3e768048e0e8c832077a545ca07792631c59edaee925e3e67401c2a0", size = 20514177, upload-time = "2025-10-07T10:36:55.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/21/08f10706d30252753349ec545833fc0cea67c11abd0b5223acf2827f1056/duckdb-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:567f3b3a785a9e8650612461893c49ca799661d2345a6024dda48324ece89ded", size = 12336422, upload-time = "2025-10-07T10:36:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/d7/08/705988c33e38665c969f7876b3ca4328be578554aa7e3dc0f34158da3e64/duckdb-1.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:46496a2518752ae0c6c5d75d4cdecf56ea23dd098746391176dd8e42cf157791", size = 29077070, upload-time = "2025-10-07T10:36:59.83Z" }, + { url = "https://files.pythonhosted.org/packages/99/c5/7c9165f1e6b9069441bcda4da1e19382d4a2357783d37ff9ae238c5c41ac/duckdb-1.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1c65ae7e9b541cea07d8075343bcfebdecc29a3c0481aa6078ee63d51951cfcd", size = 16167506, upload-time = "2025-10-07T10:37:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/38/46/267f4a570a0ee3ae6871ddc03435f9942884284e22a7ba9b7cb252ee69b6/duckdb-1.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:598d1a314e34b65d9399ddd066ccce1eeab6a60a2ef5885a84ce5ed62dbaf729", size = 13762330, upload-time = "2025-10-07T10:37:04.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/7b/c4f272a40c36d82df20937d93a1780eb39ab0107fe42b62cba889151eab9/duckdb-1.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2f16b8def782d484a9f035fc422bb6f06941ed0054b4511ddcdc514a7fb6a75", size = 18504687, upload-time = "2025-10-07T10:37:06.991Z" }, + { url = "https://files.pythonhosted.org/packages/17/fc/9b958751f0116d7b0406406b07fa6f5a10c22d699be27826d0b896f9bf51/duckdb-1.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5a7d0aed068a5c33622a8848857947cab5cfb3f2a315b1251849bac2c74c492", size = 20513823, upload-time = "2025-10-07T10:37:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/79/4f544d73fcc0513b71296cb3ebb28a227d22e80dec27204977039b9fa875/duckdb-1.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:280fd663dacdd12bb3c3bf41f3e5b2e5b95e00b88120afabb8b8befa5f335c6f", size = 12336460, upload-time = "2025-10-07T10:37:12.154Z" }, +] + [[package]] name = "ecdsa" version = "0.19.1" @@ -690,6 +722,7 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "cryptography" }, { name = "db-dtypes" }, + { name = "duckdb" }, { name = "fastmcp" }, { name = "google-cloud-bigquery" }, { name = "httpx" }, @@ -710,6 +743,7 @@ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.12.0" }, { name = "cryptography", specifier = ">=41.0.0" }, { name = "db-dtypes", specifier = ">=1.0.0" }, + { name = "duckdb", specifier = ">=1.4.1" }, { name = "fastmcp", specifier = ">=0.1.0" }, { name = "google-cloud-bigquery", specifier = ">=3.0.0" }, { name = "httpx", specifier = ">=0.24.0" }, From 97adb961a970bd82a5d165c0680fe849db778fd7 Mon Sep 17 00:00:00 2001 From: hill Date: Mon, 27 Oct 2025 18:11:55 -0400 Subject: [PATCH 02/13] Add duckdb to CLI config command and make MCP tool calls compatible with duckdb backend. Also, fixed the working path to not be m3/src/m3 anymore, but m3/ instead. --- src/m3/cli.py | 35 ++++------ .../mcp_client_configs/dynamic_mcp_config.py | 47 ++++++++++---- .../setup_claude_desktop.py | 18 +++-- src/m3/mcp_server.py | 65 ++++++++++++++++--- 4 files changed, 116 insertions(+), 49 deletions(-) diff --git a/src/m3/cli.py b/src/m3/cli.py index 7450e74..0035ae6 100644 --- a/src/m3/cli.py +++ b/src/m3/cli.py @@ -232,7 +232,7 @@ def config_cmd( typer.Option( "--backend", "-b", - help="Backend to use (sqlite or bigquery). Default: sqlite", + help="Backend to use (sqlite, duckdb, or bigquery). Default: sqlite", ), ] = "sqlite", db_path: Annotated[ @@ -240,7 +240,7 @@ def config_cmd( typer.Option( "--db-path", "-p", - help="Path to SQLite database (for sqlite backend)", + help="Path to SQLite database (for sqlite or duckdb backend)", ), ] = None, project_id: Annotated[ @@ -314,29 +314,22 @@ def config_cmd( raise typer.Exit(code=1) # Validate backend-specific arguments + # sqlite: db_path allowed, project_id not allowed if backend == "sqlite" and project_id: - typer.secho( - "❌ Error: --project-id can only be used with --backend bigquery", - fg=typer.colors.RED, - err=True, - ) + typer.secho("❌ Error: --project-id can only be used with --backend bigquery", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) - if backend == "bigquery" and db_path: - typer.secho( - "❌ Error: --db-path can only be used with --backend sqlite", - fg=typer.colors.RED, - err=True, - ) + # duckdb: db_path allowed, project_id not allowed + if backend == "duckdb" and project_id: + typer.secho("❌ Error: --project-id can only be used with --backend bigquery", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) - # Require project_id for BigQuery backend + # bigquery: requires project_id, db_path not allowed + if backend == "bigquery" and db_path: + typer.secho("❌ Error: --db-path can only be used with --backend sqlite or duckdb", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) if backend == "bigquery" and not project_id: - typer.secho( - "❌ Error: --project-id is required when using --backend bigquery", - fg=typer.colors.RED, - err=True, - ) + typer.secho("❌ Error: --project-id is required when using --backend bigquery", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) if client == "claude": @@ -357,7 +350,7 @@ def config_cmd( if backend != "sqlite": cmd.extend(["--backend", backend]) - if backend == "sqlite" and db_path: + if backend in ("sqlite", "duckdb") and db_path: cmd.extend(["--db-path", db_path]) elif backend == "bigquery" and project_id: cmd.extend(["--project-id", project_id]) @@ -413,7 +406,7 @@ def config_cmd( if working_directory: cmd.extend(["--working-directory", working_directory]) - if backend == "sqlite" and db_path: + if backend in ("sqlite", "duckdb") and db_path: cmd.extend(["--db-path", db_path]) elif backend == "bigquery" and project_id: cmd.extend(["--project-id", project_id]) diff --git a/src/m3/mcp_client_configs/dynamic_mcp_config.py b/src/m3/mcp_client_configs/dynamic_mcp_config.py index d49d776..adb0a07 100644 --- a/src/m3/mcp_client_configs/dynamic_mcp_config.py +++ b/src/m3/mcp_client_configs/dynamic_mcp_config.py @@ -21,7 +21,10 @@ class MCPConfigGenerator: """Generator for MCP server configurations.""" def __init__(self): - self.current_dir = Path(__file__).parent.parent.absolute() + p = Path(__file__).resolve() + while p != p.parent and not (p / "pyproject.toml").exists(): + p = p.parent + self.current_dir = p self.default_python = self._get_default_python() def _get_default_python(self) -> str: @@ -78,7 +81,7 @@ def generate_config( } # Add backend-specific environment variables - if backend == "sqlite" and db_path: + if backend in ("sqlite", "duckdb") and db_path: env["M3_DB_PATH"] = db_path elif backend == "bigquery" and project_id: env["M3_PROJECT_ID"] = project_id @@ -155,15 +158,16 @@ def interactive_config(self) -> dict[str, Any]: # Backend selection - simplified print("\nChoose backend:") print("1. SQLite (local database)") - print("2. BigQuery (Google Cloud)") + print("2. DuckDB (local database)") + print("3. BigQuery (Google Cloud)") while True: backend_choice = input("Choose backend [1]: ").strip() or "1" - if backend_choice in ["1", "2"]: + if backend_choice in ["1", "2", "3"]: break - print("Please enter 1 or 2") + print("Please enter 1, 2, or 3") - backend = "sqlite" if backend_choice == "1" else "bigquery" + backend = "sqlite" if backend_choice == "1" else "duckdb" if backend_choice == "2" else "bigquery" # Backend-specific configuration db_path = None @@ -185,6 +189,22 @@ def interactive_config(self) -> dict[str, Any]: or None ) + elif backend == "duckdb": + print("\n📁 DuckDB Configuration:") + from m3.config import get_default_database_path + + default_db_path = get_default_database_path("mimic-iv-demo", engine="duckdb") + if default_db_path is None: + raise ValueError(_DATABASE_PATH_ERROR_MSG) + print(f"Default database path: {default_db_path}") + + db_path = ( + input( + "DuckDB database path (optional, press Enter to use default): " + ).strip() + or None + ) + elif backend == "bigquery": print("\n☁️ BigQuery Configuration:") project_id = None @@ -278,11 +298,12 @@ def print_config_info(config: dict[str, Any]): if "M3_DB_PATH" in server_config["env"]: print(f"💾 Database path: {server_config['env']['M3_DB_PATH']}") - elif server_config["env"].get("M3_BACKEND") == "sqlite": - # Show the default path when using SQLite backend + elif server_config["env"].get("M3_BACKEND") in ("sqlite", "duckdb"): + # Show the default path when using SQLite or DuckDB backend from m3.config import get_default_database_path + engine = server_config["env"].get("M3_BACKEND") - default_path = get_default_database_path("mimic-iv-demo") + default_path = get_default_database_path("mimic-iv-demo", engine=engine) if default_path is None: raise ValueError(_DATABASE_PATH_ERROR_MSG) print(f"💾 Database path: {default_path}") @@ -344,12 +365,12 @@ def main(): parser.add_argument("--working-directory", help="Working directory for the server") parser.add_argument( "--backend", - choices=["sqlite", "bigquery"], + choices=["sqlite", "duckdb", "bigquery"], default="sqlite", help="Backend to use (default: sqlite)", ) parser.add_argument( - "--db-path", help="Path to SQLite database (for sqlite backend)" + "--db-path", help="Path to SQLite or DuckDB database (for sqlite or duckdb backend)" ) parser.add_argument( "--project-id", help="Google Cloud project ID (for bigquery backend)" @@ -372,7 +393,7 @@ def main(): args = parser.parse_args() # Validate backend-specific arguments - if args.backend == "sqlite" and args.project_id: + if args.backend in ("sqlite", "duckdb") and args.project_id: print( "❌ Error: --project-id can only be used with --backend bigquery", file=sys.stderr, @@ -381,7 +402,7 @@ def main(): if args.backend == "bigquery" and args.db_path: print( - "❌ Error: --db-path can only be used with --backend sqlite", + "❌ Error: --db-path can only be used with --backend sqlite or duckdb", file=sys.stderr, ) sys.exit(1) diff --git a/src/m3/mcp_client_configs/setup_claude_desktop.py b/src/m3/mcp_client_configs/setup_claude_desktop.py index e3d0ab9..77594ab 100644 --- a/src/m3/mcp_client_configs/setup_claude_desktop.py +++ b/src/m3/mcp_client_configs/setup_claude_desktop.py @@ -41,7 +41,10 @@ def get_claude_config_path(): def get_current_directory(): """Get the current M3 project directory.""" - return Path(__file__).parent.parent.absolute() + p = Path(__file__).resolve() + while p != p.parent and not (p / "pyproject.toml").exists(): + p = p.parent + return p def get_python_path(): @@ -79,7 +82,7 @@ def create_mcp_config( } # Add backend-specific environment variables - if backend == "sqlite" and db_path: + if backend in ("sqlite", "duckdb") and db_path: config["mcpServers"]["m3"]["env"]["M3_DB_PATH"] = db_path elif backend == "bigquery" and project_id: config["mcpServers"]["m3"]["env"]["M3_PROJECT_ID"] = project_id @@ -162,6 +165,9 @@ def setup_claude_desktop( if backend == "sqlite": db_path_display = db_path or "default (m3_data/databases/mimic_iv_demo.db)" print(f"💾 Database: {db_path_display}") + elif backend == "duckdb": + db_path_display = db_path or "default (m3_data/databases/mimic_iv_demo.duckdb)" + print(f"💾 Database: {db_path_display}") elif backend == "bigquery": project_display = project_id or "physionet-data" print(f"☁️ Project: {project_display}") @@ -201,12 +207,12 @@ def main(): ) parser.add_argument( "--backend", - choices=["sqlite", "bigquery"], + choices=["sqlite", "duckdb", "bigquery"], default="sqlite", help="Backend to use (default: sqlite)", ) parser.add_argument( - "--db-path", help="Path to SQLite database (for sqlite backend)" + "--db-path", help="Path to SQLite or DuckDB database (for sqlite or duckdb backend)" ) parser.add_argument( "--project-id", help="Google Cloud project ID (for bigquery backend)" @@ -227,12 +233,12 @@ def main(): args = parser.parse_args() # Validate backend-specific arguments - if args.backend == "sqlite" and args.project_id: + if args.backend in ("sqlite", "duckdb") and args.project_id: print("❌ Error: --project-id can only be used with --backend bigquery") exit(1) if args.backend == "bigquery" and args.db_path: - print("❌ Error: --db-path can only be used with --backend sqlite") + print("❌ Error: --db-path can only be used with --backend sqlite or duckdb") exit(1) # Require project_id for BigQuery backend diff --git a/src/m3/mcp_server.py b/src/m3/mcp_server.py index cc8bd91..0a0d47c 100644 --- a/src/m3/mcp_server.py +++ b/src/m3/mcp_server.py @@ -5,6 +5,7 @@ import os import sqlite3 +import duckdb from pathlib import Path import pandas as pd @@ -149,6 +150,15 @@ def _init_backend(): if not Path(_db_path).exists(): raise FileNotFoundError(f"SQLite database not found: {_db_path}") + elif _backend == "duckdb": + _db_path = os.getenv("M3_DB_PATH") + if not _db_path: + from m3.config import get_default_database_path + path = get_default_database_path("mimic-iv-demo", engine="duckdb") + _db_path = str(path) if path else None + if not _db_path or not Path(_db_path).exists(): + raise FileNotFoundError(f"DuckDB database not found: {_db_path}") + elif _backend == "bigquery": try: from google.cloud import bigquery @@ -177,6 +187,8 @@ def _get_backend_info() -> str: """Get current backend information for display in responses.""" if _backend == "sqlite": return f"🔧 **Current Backend:** SQLite (local database)\n📁 **Database Path:** {_db_path}\n" + elif _backend == "duckdb": + return f"🔧 **Current Backend:** DuckDB (local database)\n📁 **Database Path:** {_db_path}\n" else: return f"🔧 **Current Backend:** BigQuery (cloud database)\n☁️ **Project ID:** {_project_id}\n" @@ -214,6 +226,26 @@ def _execute_sqlite_query(sql_query: str) -> str: raise e +def _execute_duckdb_query(sql_query: str) -> str: + """Execute DuckDB query - internal function.""" + try: + conn = duckdb.connect(_db_path) + try: + df = conn.execute(sql_query).df() + if df.empty: + return "No results found" + if len(df) > 50: + out = df.head(50).to_string(index=False) + f"\n... ({len(df)} total rows, showing first 50)" + else: + out = df.to_string(index=False) + return out + finally: + conn.close() + except Exception as e: + # Re-raise the exception so the calling function can handle it with enhanced guidance + raise e + + def _execute_bigquery_query(sql_query: str) -> str: """Execute BigQuery query - internal function.""" try: @@ -261,6 +293,8 @@ def _execute_query_internal(sql_query: str) -> str: try: if _backend == "sqlite": return _execute_sqlite_query(sql_query) + elif _backend == "duckdb": + return _execute_duckdb_query(sql_query) else: # bigquery return _execute_bigquery_query(sql_query) except Exception as e: @@ -360,6 +394,18 @@ def get_database_schema() -> str: query = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" result = _execute_query_internal(query) return f"{_get_backend_info()}\n📋 **Available Tables:**\n{result}" + + elif _backend == "duckdb": + query = """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'main' + ORDER BY table_name + """ + result = _execute_query_internal(query) + return f"{_get_backend_info()}\n📋 **Available Tables:**\n{result}" + + else: # bigquery # Show fully qualified table names that are ready to copy-paste into queries query = """ @@ -397,19 +443,20 @@ def get_table_info(table_name: str, show_sample: bool = True) -> str: """ backend_info = _get_backend_info() - if _backend == "sqlite": + if _backend in ["sqlite", "duckdb"]: # Get column information - pragma_query = f"PRAGMA table_info({table_name})" + pragma_query = f"PRAGMA table_info('{table_name}')" + execute_query_fn = _execute_sqlite_query if _backend == "sqlite" else _execute_duckdb_query try: - result = _execute_sqlite_query(pragma_query) + result = execute_query_fn(pragma_query) if "error" in result.lower(): return f"{backend_info}❌ Table '{table_name}' not found. Use get_database_schema() to see available tables." info_result = f"{backend_info}📋 **Table:** {table_name}\n\n**Column Information:**\n{result}" if show_sample: - sample_query = f"SELECT * FROM {table_name} LIMIT 3" - sample_result = _execute_sqlite_query(sample_query) + sample_query = f"SELECT * FROM '{table_name}' LIMIT 3" + sample_result = execute_query_fn(sample_query) info_result += ( f"\n\n📊 **Sample Data (first 3 rows):**\n{sample_result}" ) @@ -430,7 +477,7 @@ def get_table_info(table_name: str, show_sample: bool = True) -> str: # Validate BigQuery qualified name format: project.dataset.table if len(parts) != 3: error_msg = ( - f"{_get_backend_info()}❌ **Invalid qualified table name:** `{table_name}`\n\n" + f"{backend_info}❌ **Invalid qualified table name:** `{table_name}`\n\n" "**Expected format:** `project.dataset.table`\n" "**Example:** `physionet-data.mimiciv_3_1_hosp.diagnoses_icd`\n\n" "**Available MIMIC-IV datasets:**\n" @@ -553,7 +600,7 @@ def get_icu_stays(patient_id: int | None = None, limit: int = 10) -> str: return "Error: Invalid limit. Must be a positive integer between 1 and 10000." # Try common ICU table names based on backend - if _backend == "sqlite": + if _backend == "sqlite" or _backend == "duckdb": icustays_table = "icu_icustays" else: # bigquery icustays_table = "`physionet-data.mimiciv_3_1_icu.icustays`" @@ -603,7 +650,7 @@ def get_lab_results( return "Error: Invalid limit. Must be a positive integer between 1 and 10000." # Try common lab table names based on backend - if _backend == "sqlite": + if _backend == "sqlite" or _backend == "duckdb": labevents_table = "hosp_labevents" else: # bigquery labevents_table = "`physionet-data.mimiciv_3_1_hosp.labevents`" @@ -658,7 +705,7 @@ def get_race_distribution(limit: int = 10) -> str: return "Error: Invalid limit. Must be a positive integer between 1 and 10000." # Try common admissions table names based on backend - if _backend == "sqlite": + if _backend == "sqlite" or _backend == "duckdb": admissions_table = "hosp_admissions" else: # bigquery admissions_table = "`physionet-data.mimiciv_3_1_hosp.admissions`" From bd8638c3d10abd5b4cb62cf28eb65d17a373f8d0 Mon Sep 17 00:00:00 2001 From: hill Date: Tue, 28 Oct 2025 15:58:50 -0400 Subject: [PATCH 03/13] Add mimic-iv-full dataset support, but it has to be downloaded and copied into m3_data/raw manually. --- src/m3/config.py | 10 ++- src/m3/data_io.py | 184 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 165 insertions(+), 29 deletions(-) diff --git a/src/m3/config.py b/src/m3/config.py index 666283b..f04e32e 100644 --- a/src/m3/config.py +++ b/src/m3/config.py @@ -48,9 +48,15 @@ def _get_project_root() -> Path: "subdirectories_to_scan": ["hosp", "icu"], "default_db_filename": "mimic_iv_demo.db", "default_duckdb_filename": "mimic_iv_demo.duckdb", - "primary_verification_table": "hosp_admissions", # Table name in SQLite DB + "primary_verification_table": "hosp_admissions", + }, + "mimic-iv-full": { + "file_listing_url": None, + "subdirectories_to_scan": ["hosp", "icu"], + "default_db_filename": "mimic_iv_full.db", + "default_duckdb_filename": "mimic_iv_full.duckdb", + "primary_verification_table": "hosp_admissions", }, - # add other datasets here... } diff --git a/src/m3/data_io.py b/src/m3/data_io.py index 9b966cb..2e26713 100644 --- a/src/m3/data_io.py +++ b/src/m3/data_io.py @@ -6,6 +6,10 @@ import typer from bs4 import BeautifulSoup import duckdb +import time +from datetime import timedelta +from concurrent.futures import ThreadPoolExecutor, as_completed +import os from m3.config import get_dataset_config, get_dataset_raw_files_path, logger @@ -342,53 +346,177 @@ def initialize_dataset(dataset_name: str, db_target_path: Path) -> bool: def _csv_to_parquet_all(src_root: Path, parquet_root: Path) -> bool: """ Convert all CSV files in the source directory to Parquet files. + - Streams via DuckDB COPY to keep memory low + - Low concurrency to avoid parallel memory spikes + - Tunable via env: + M3_CONVERT_MAX_WORKERS (default: 4) + M3_DUCKDB_MEM (default: 3GB) + M3_DUCKDB_THREADS (default: 2) """ parquet_paths: list[Path] = [] - for csv_gz in src_root.rglob("*.csv.gz"): + csv_files = list(src_root.rglob("*.csv.gz")) + if not csv_files: + logger.error(f"No CSV files found in {src_root}") + return False + + # Optional: process small files first so progress moves smoothly + try: + csv_files.sort(key=lambda p: p.stat().st_size) + except Exception: + pass + + def _convert_one(csv_gz: Path) -> tuple[Path | None, float]: + """Convert one CSV file and return the output path and time taken.""" + start = time.time() rel = csv_gz.relative_to(src_root) out = parquet_root / rel.with_suffix("").with_suffix(".parquet") out.parent.mkdir(parents=True, exist_ok=True) + + con = duckdb.connect() try: - df = pl.read_csv( - source=str(csv_gz), - infer_schema_length=None, - try_parse_dates=True, - ignore_errors=False, - null_values=["", "NULL", "null", "\\N", "NA"], - ) - df.write_parquet(str(out)) - parquet_paths.append(out) - except Exception as e: - logger.error(f"Parquet conversion failed for {csv_gz}: {e}") - return False - logger.info(f"Converted {len(parquet_paths)} files to Parquet under {parquet_root}") + mem_limit = os.environ.get("M3_DUCKDB_MEM", "3GB") + threads = int(os.environ.get("M3_DUCKDB_THREADS", "2")) + con.execute(f"SET memory_limit='{mem_limit}'") + con.execute(f"PRAGMA threads={threads}") + + # Streamed CSV -> Parquet conversion + # 'all_varchar=true' avoids expensive/wide type inference; + # if you prefer typed inference, drop it and keep sample_size=-1. + sql = f""" + COPY ( + SELECT * FROM read_csv_auto('{csv_gz.as_posix()}', sample_size=-1, all_varchar=true) + ) + TO '{out.as_posix()}' (FORMAT PARQUET, COMPRESSION ZSTD); + """ + con.execute(sql) + elapsed = time.time() - start + return out, elapsed + finally: + con.close() + + start_time = time.time() + cpu_cnt = os.cpu_count() or 4 + # Increase default to 2 for better throughput + max_workers = max(1, int(os.environ.get("M3_CONVERT_MAX_WORKERS", "4"))) + + total_files = len(csv_files) + completed = 0 + + with ThreadPoolExecutor(max_workers=max_workers) as ex: + futures = {ex.submit(_convert_one, f): f for f in csv_files} + + # Manual progress tracking for better time estimates + logger.info(f"Converting {total_files} CSV files to Parquet using {max_workers} workers...") + + for fut in as_completed(futures): + try: + result_path, file_time = fut.result() + if result_path is not None: + parquet_paths.append(result_path) + completed += 1 + + # Log elapsed time + elapsed = time.time() - start_time + logger.info( + f"Progress: {completed}/{total_files} files " + f"({100*completed/total_files:.1f}%) - " + f"Elapsed: {str(timedelta(seconds=int(elapsed)))} - " + ) + except Exception as e: + csv_file = futures[fut] + logger.error(f"Parquet conversion failed for {csv_file}: {e}") + ex.shutdown(cancel_futures=True) + return False + + elapsed_time = time.time() - start_time + logger.info( + f"✓ Converted {len(parquet_paths)} files to Parquet under {parquet_root} " + f"in {str(timedelta(seconds=int(elapsed_time)))}" + ) return True + def _create_duckdb_with_views(db_path: Path, parquet_root: Path) -> bool: """ - Create a DuckDB database from existing Parquet files and create views for each table. + Create a DuckDB database and define one view per Parquet file, + using the proper table naming structure that matches MIMIC-IV expectations. + + For example: + - hosp/admissions.parquet → view: hosp_admissions + - icu/chartevents.parquet → view: icu_chartevents """ con = duckdb.connect(str(db_path)) try: - for pq in parquet_root.rglob("*.parquet"): + # Find all parquet files + parquet_files = list(parquet_root.rglob("*.parquet")) + if not parquet_files: + logger.error(f"No Parquet files found in {parquet_root}") + return False + + # Optimize DuckDB settings + cpu_count = os.cpu_count() or 4 + con.execute(f"PRAGMA threads={cpu_count}") + con.execute("SET memory_limit='8GB'") # adjust to your machine + + logger.info(f"Creating {len(parquet_files)} views in DuckDB...") + start_time = time.time() + created = 0 + + for idx, pq in enumerate(parquet_files, 1): + # Get relative path from parquet_root rel = pq.relative_to(parquet_root) - parts = [p.lower() for p in rel.parts] - table = ( - "_".join(parts) - .replace(".parquet", "") - .replace("-", "_") - .replace(".", "_") + + # Build view name from directory structure + filename + # e.g., hosp/admissions.parquet -> hosp_admissions + parts = list(rel.parent.parts) + [rel.stem] # stem removes .parquet + + # Clean and join parts + view_name = "_".join( + p.lower().replace("-", "_").replace(".", "_") + for p in parts if p != "." ) - sql = ( - f"CREATE OR REPLACE VIEW {table} " - f"AS SELECT * FROM read_parquet('{pq.as_posix()}');" - ) - con.execute(sql) + + # Create view pointing to the specific parquet file + sql = f""" + CREATE OR REPLACE VIEW {view_name} AS + SELECT * FROM read_parquet('{pq.as_posix()}'); + """ + + try: + con.execute(sql) + created += 1 + + # Progress logging + if idx % 5 == 0 or idx == len(parquet_files): + elapsed = time.time() - start_time + avg_time = elapsed / idx + eta_seconds = avg_time * (len(parquet_files) - idx) + logger.info( + f"Progress: {idx}/{len(parquet_files)} views " + f"({100*idx/len(parquet_files):.1f}%) - " + f"Last: {view_name} - " + f"ETA: {str(timedelta(seconds=int(eta_seconds)))}" + ) + except Exception as e: + logger.error(f"Failed to create view {view_name} from {pq}: {e}") + raise + con.commit() + elapsed_time = time.time() - start_time + logger.info( + f"✓ Created {created} views in {db_path} in " + f"{str(timedelta(seconds=int(elapsed_time)))}" + ) + + # List all created views for verification + views_result = con.execute("SELECT name FROM sqlite_master WHERE type='view' ORDER BY name").fetchall() + logger.info(f"Created views: {', '.join(v[0] for v in views_result[:10])}{'...' if len(views_result) > 10 else ''}") + return True finally: con.close() + def build_duckdb_from_existing_raw(dataset_name: str, db_target_path: Path) -> bool: """ Build a DuckDB database from existing raw CSVs (no downloads). @@ -410,6 +538,8 @@ def build_duckdb_from_existing_raw(dataset_name: str, db_target_path: Path) -> b if not _csv_to_parquet_all(raw_files_root_dir, parquet_root): return False + logger.info("✓ Created all parquet files") + return _create_duckdb_with_views(db_target_path, parquet_root) From 1e567545815c9422e3c437169a62b6eb13e31c86 Mon Sep 17 00:00:00 2001 From: hill Date: Tue, 28 Oct 2025 19:06:51 -0400 Subject: [PATCH 04/13] feat: switch local backend to DuckDB + Parquet; add download,convert; keep init fast (views only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove SQLite; unify local backends on DuckDB + Parquet for demo and full * CLI: * add m3 download (demo only) to fetch CSV.gz * add m3 convert (demo/full) to convert CSV.gz → Parquet * m3 init now creates/refreshes DuckDB views over existing Parquet * Update status/use/config to new dataset model * Refresh tests and README for new workflow --- README.md | 229 ++++- pyproject.toml | 4 +- src/m3/cli.py | 315 ++++-- src/m3/config.py | 139 ++- src/m3/data_io.py | 368 +++---- .../mcp_client_configs/dynamic_mcp_config.py | 45 +- .../setup_claude_desktop.py | 23 +- src/m3/mcp_server.py | 77 +- tests/test_cli.py | 76 +- tests/test_config.py | 14 +- tests/test_config_scripts.py | 18 +- tests/test_data_io.py | 59 +- tests/test_mcp_server.py | 123 +-- uv.lock | 965 ++++++++++++++++++ 14 files changed, 1835 insertions(+), 620 deletions(-) diff --git a/README.md b/README.md index 0fb37c7..b7c21f3 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ Transform medical data analysis with AI! Ask questions about MIMIC-IV data in pl ## Features -- **Natural Language Queries**: Ask questions about MIMIC-IV data in plain English -- **Local SQLite**: Fast queries on demo database (free, no setup) -- **BigQuery Support**: Access full MIMIC-IV dataset on Google Cloud -- **Enterprise Security**: OAuth2 authentication with JWT tokens and rate limiting -- **SQL Injection Protection**: Read-only queries with comprehensive validation +- 🔍 **Natural Language Queries**: Ask questions about MIMIC-IV data in plain English +- 🏠 **Local DuckDB + Parquet**: Fast local queries for demo and full dataset using Parquet files with DuckDB views +- ☁️ **BigQuery Support**: Access full MIMIC-IV dataset on Google Cloud +- 🔒 **Enterprise Security**: OAuth2 authentication with JWT tokens and rate limiting +- 🛡️ **SQL Injection Protection**: Read-only queries with comprehensive validation ## 🚀 Quick Start @@ -47,7 +47,7 @@ uv --version ### BigQuery Setup (Optional - Full Dataset) -**Skip this if using SQLite demo database.** +**Skip this if using DuckDB demo database.** 1. **Install Google Cloud SDK:** - macOS: `brew install google-cloud-sdk` @@ -69,7 +69,7 @@ Paste one of the following into your MCP client config, then restart your client -**SQLite (Demo Database)** +**DuckDB (Demo Database)** Free, local, no setup required. @@ -80,7 +80,7 @@ Free, local, no setup required. "command": "uvx", "args": ["m3-mcp"], "env": { - "M3_BACKEND": "sqlite" + "M3_BACKEND": "duckdb" } } } @@ -126,13 +126,13 @@ Requires GCP credentials and PhysioNet access. ## Backend Comparison -| Feature | SQLite (Demo) | BigQuery (Full) | -|---------|---------------|-----------------| -| **Cost** | Free | BigQuery usage fees | -| **Setup** | Zero config | GCP credentials required | -| **Data Size** | 100 patients, 275 admissions | 365k patients, 546k admissions | -| **Speed** | Fast (local) | Network latency | -| **Use Case** | Learning, development | Research, production | +| Feature | DuckDB (Demo) | DuckDB (Full) | BigQuery (Full) | +|---------|---------------|---------------|-----------------| +| **Cost** | Free | Free | BigQuery usage fees | +| **Setup** | Zero config | Manual Download | GCP credentials required | +| **Data Size** | 100 patients, 275 admissions | 365k patients, 546k admissions | 365k patients, 546k admissions | +| **Speed** | Fast (local) | Fast (local) | Network latency | +| **Use Case** | Learning, development | Research (local) | Research, production | --- @@ -146,7 +146,7 @@ Requires GCP credentials and PhysioNet access. -**SQLite:** +**DuckDB (Local):** ```bash git clone https://github.com/rafiattrach/m3.git && cd m3 docker build -t m3:lite --target lite . @@ -205,7 +205,7 @@ pip install m3-mcp "m3": { "command": "m3-mcp-server", "env": { - "M3_BACKEND": "sqlite" + "M3_BACKEND": "duckdb" } } } @@ -233,14 +233,160 @@ pre-commit install "args": ["-m", "m3.mcp_server"], "cwd": "/path/to/m3", "env": { - "M3_BACKEND": "sqlite" + "M3_BACKEND": "duckdb" } } } } ``` -## Advanced Configuration +#### Using `UV` (Recommended) +Assuming you have [UV](https://docs.astral.sh/uv/getting-started/installation/) installed. + +**Step 1: Clone and Navigate** +```bash +# Clone the repository +git clone https://github.com/rafiattrach/m3.git +cd m3 +``` + +**Step 2: Create `UV` Virtual Environment** +```bash +# Create virtual environment +uv venv +``` + +**Step 3: Install M3** +```bash +uv sync +# Do not forget to use `uv run` to any subsequent commands to ensure you're using the `uv` virtual environment +``` + +### 🗄️ Database Configuration + +After installation, choose your data source: + +#### Option A: Local Demo (DuckDB + Parquet) + +**Perfect for learning and development - completely free!** + +1. **Download demo CSVs**: + ```bash + m3 download mimic-iv-demo + ``` +2. **Convert CSV to Parquet**: + ```bash + m3 convert mimic-iv-demo + ``` +3. **Create DuckDB views over Parquet**: + ```bash + m3 init mimic-iv-demo + ``` + +4. **Setup MCP Client**: + ```bash + m3 config + ``` + + *Alternative: For Claude Desktop specifically:* + ```bash + m3 config claude --backend duckdb --db-path /Users/you/path/to/m3_data/databases/mimic_iv_demo.duckdb + ``` + +5. **Restart your MCP client** and ask: + + - "What tools do you have for MIMIC-IV data?" + - "Show me patient demographics from the ICU" + +#### Option B: Local Full Dataset (DuckDB + Parquet) + +**Run the entire MIMIC-IV dataset locally with DuckDB views over Parquet.** + +1. **Acquire CSVs** (requires PhysioNet credentials): + - Download the official MIMIC-IV CSVs from PhysioNet and place them under: + - `/Users/you/path/to/m3/m3_data/raw_files/mimic-iv-full/hosp/` + - `/Users/you/path/to/m3/m3_data/raw_files/mimic-iv-full/icu/` + - Note: `m3 download` currently supports the demo only. Use your browser or `wget` to obtain the full dataset. + +2. **Convert CSV → Parquet** (streaming via DuckDB): + ```bash + m3 convert mimic-iv-full + ``` + - Default destination: `/Users/you/path/to/m3/m3_data/parquet/mimic-iv-full/` + - Performance knobs (optional): + ```bash + export M3_CONVERT_MAX_WORKERS=6 # number of parallel files (default=4) + export M3_DUCKDB_MEM=4GB # DuckDB memory limit per worker (default=3GB) + export M3_DUCKDB_THREADS=4 # DuckDB threads per worker (default=2) + ``` + Pay attention to your system specifications, especially if you have enough memory. + +3. **Create DuckDB views over Parquet**: + ```bash + m3 init mimic-iv-full + ``` + - Database path: `/Users/you/path/to/m3/m3_data/databases/mimic_iv_full.duckdb` + +4. **Select dataset and verify**: + ```bash + m3 use full + m3 status + ``` + - Status prints active dataset, local DB path, Parquet presence, quick row counts and total Parquet size. + +5. **Configure MCP client** (uses the full local DB): + ```bash + m3 config + # or + m3 config claude --backend duckdb --db-path /Users/you/path/to/m3/m3_data/databases/mimic_iv_full.duckdb + ``` + +#### Option C: BigQuery (Full Dataset) + +**For researchers needing complete MIMIC-IV data** + +##### Prerequisites +- Google Cloud account and project with billing enabled +- Access to MIMIC-IV on BigQuery (requires PhysioNet credentialing) + +##### Setup Steps + +1. **Install Google Cloud CLI**: + + **macOS (with Homebrew):** + ```bash + brew install google-cloud-sdk + ``` + + **Windows:** Download from https://cloud.google.com/sdk/docs/install + + **Linux:** + ```bash + curl https://sdk.cloud.google.com | bash + ``` + +2. **Authenticate**: + ```bash + gcloud auth application-default login + ``` + *This will open your browser - choose the Google account that has access to your BigQuery project with MIMIC-IV data.* + +3. **Setup MCP Client for BigQuery**: + ```bash + m3 config + ``` + + *Alternative: For Claude Desktop specifically:* + ```bash + m3 config claude --backend bigquery --project-id YOUR_PROJECT_ID + ``` + +4. **Test BigQuery Access** - Restart your MCP client and ask: + ``` + Use the get_race_distribution function to show me the top 5 races in MIMIC-IV admissions. + ``` + +## 🔧 Advanced Configuration Need to configure other MCP clients or customize settings? Use these commands: @@ -255,8 +401,8 @@ Generates configuration for any MCP client with step-by-step guidance. # Quick universal config with defaults m3 config --quick -# Universal config with custom database -m3 config --quick --backend sqlite --db-path /path/to/database.db +# Universal config with custom DuckDB database +m3 config --quick --backend duckdb --db-path /path/to/database.duckdb # Save config to file for other MCP clients m3 config --output my_config.json @@ -291,7 +437,21 @@ m3 config # Choose OAuth2 option during setup --- -## Available MCP Tools +## Backend Details + +**DuckDB Backend (Local)** +- ✅ **Free**: No cloud costs +- ✅ **Fast**: Local queries over Parquet +- ✅ **Easy**: No authentication needed +- ❌ **Big download size**: Manual download for the full dataset required + +**BigQuery Backend** +- ✅ **Complete**: Full MIMIC-IV dataset (~500k admissions) +- ✅ **Scalable**: Google Cloud infrastructure +- ✅ **Current**: Latest MIMIC-IV version (3.1) +- ❌ **Costs**: BigQuery usage fees apply + +## 🛠️ Available MCP Tools When your MCP client processes questions, it uses these tools automatically: @@ -323,13 +483,22 @@ Try asking your MCP client these questions: - `Prompt:` *What tables are available in the database?* - `Prompt:` *What tools do you have for MIMIC-IV data?* -## Troubleshooting +## 🎩 Pro Tips + +- Do you want to pre-approve the usage of all tools in Claude Desktop? Use the prompt below and then select **Always Allow** + - `Prompt:` *Can you please call all your tools in a logical sequence?* + +## 🔍 Troubleshooting ### Common Issues -**SQLite "Database not found" errors:** +**Local "Parquet not found" or view errors:** ```bash -# Re-download demo database +# 1) Download CSVs (demo only in current version) +m3 download mimic-iv-demo +# 2) Convert CSV → Parquet +m3 convert mimic-iv-demo +# 3) Create/refresh views m3 init mimic-iv-demo ``` @@ -409,11 +578,11 @@ m3-mcp-server ## Roadmap -- **Local Full Dataset**: Complete MIMIC-IV locally (no cloud costs) -- **Advanced Tools**: More specialized medical data functions -- **Visualization**: Built-in plotting and charting tools -- **Enhanced Security**: Role-based access control, audit logging -- **Multi-tenant Support**: Organization-level data isolation +- 🏠 **Complete Local Full Dataset**: Complete the support for `mimic-iv-full` (Download CLI) +- 🔧 **Advanced Tools**: More specialized medical data functions +- 📊 **Visualization**: Built-in plotting and charting tools +- 🔐 **Enhanced Security**: Role-based access control, audit logging +- 🌐 **Multi-tenant Support**: Organization-level data isolation ## Contributing diff --git a/pyproject.toml b/pyproject.toml index fb71657..5e7c085 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ maintainers = [ ] readme = "README.md" license = "MIT" -keywords = ["mimic-iv", "clinical-data", "mcp", "llm", "medical", "healthcare", "sqlite", "bigquery"] +keywords = ["mimic-iv", "clinical-data", "mcp", "llm", "medical", "healthcare", "duckdb", "bigquery"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Science/Research", @@ -53,7 +53,7 @@ dependencies = [ "duckdb>=1.4.1", ] -[project.dependency-groups] +[dependency-groups] dev = [ "ruff>=0.4.0", "pre-commit>=3.0.0", diff --git a/src/m3/cli.py b/src/m3/cli.py index 0035ae6..d67118a 100644 --- a/src/m3/cli.py +++ b/src/m3/cli.py @@ -1,5 +1,4 @@ import logging -import sqlite3 import subprocess import sys from pathlib import Path @@ -10,12 +9,21 @@ from m3 import __version__ from m3.config import ( SUPPORTED_DATASETS, + detect_available_local_datasets, + get_active_dataset, get_dataset_config, - get_dataset_raw_files_path, + get_dataset_parquet_root, get_default_database_path, logger, + set_active_dataset, +) +from m3.data_io import ( + compute_parquet_dir_size, + convert_csv_to_parquet, + download_dataset, + initialize_duckdb_from_parquet, + verify_table_rowcount, ) -from m3.data_io import initialize_dataset, build_duckdb_from_existing_raw, verify_table_rowcount app = typer.Typer( name="m3", @@ -72,7 +80,7 @@ def dataset_init_cmd( str, typer.Argument( help=( - "Dataset to initialize. Default: 'mimic-iv-demo'. " + "Dataset to initialize (local). Default: 'mimic-iv-demo'. " f"Supported: {', '.join(SUPPORTED_DATASETS.keys())}" ), metavar="DATASET_NAME", @@ -83,20 +91,15 @@ def dataset_init_cmd( typer.Option( "--db-path", "-p", - help="Custom path for the SQLite DB. Uses a default if not set.", + help="Custom path for the DuckDB file. Uses a default if not set.", ), ] = None, - engine: Annotated[ - str, - typer.Option("--engine", "-e", help="Engine to use (sqlite or duckdb). Default: sqlite"), - ] = "sqlite", ): """ - Download a supported dataset (e.g., 'mimic-iv-demo') and load it into a local SQLite + Initialize a local dataset by creating DuckDB views over existing Parquet files. - Raw downloaded files are stored in a `m3_data/raw_files//` subdirectory - and are **not** deleted after processing. - The SQLite database is stored in `m3_data/databases/` or path specified by `--db-path`. + - Parquet must already exist under /Users/hannesill/Developer/m3/m3_data/parquet// + - DuckDB file will be at /Users/hannesill/Developer/m3/m3_data/databases/.duckdb """ logger.info(f"CLI 'init' called for dataset: '{dataset_name}'") @@ -116,21 +119,10 @@ def dataset_init_cmd( ) raise typer.Exit(code=1) - # Currently, only mimic-iv-demo is fully wired up as an example. - # This check can be removed or adapted as more datasets are supported. - if dataset_key != "mimic-iv-demo": - typer.secho( - ( - f"Warning: While '{dataset_name}' is configured, only 'mimic-iv-demo' " - "is fully implemented for initialization in this version." - ), - fg=typer.colors.YELLOW, - ) - final_db_path = ( Path(db_path_str).resolve() if db_path_str - else get_default_database_path(dataset_key, engine) + else get_default_database_path(dataset_key, "duckdb") ) if not final_db_path: typer.secho( @@ -142,24 +134,22 @@ def dataset_init_cmd( # Ensure parent directory for the database exists final_db_path.parent.mkdir(parents=True, exist_ok=True) - - raw_files_storage_path = get_dataset_raw_files_path( - dataset_key - ) # Will be created if doesn't exist + parquet_root = get_dataset_parquet_root(dataset_key) typer.echo(f"Initializing dataset: '{dataset_name}'") - typer.echo(f"Target database path: {final_db_path}") - typer.echo(f"Raw files will be stored at: {raw_files_storage_path.resolve()}") + typer.echo(f"DuckDB path: {final_db_path}") + typer.echo(f"Parquet root: {parquet_root}") - - if engine == "sqlite": # Default - initialization_successful = initialize_dataset( - dataset_name=dataset_key, db_target_path=final_db_path - ) - else: # duckdb - typer.echo("Converting existing CSVs to Parquet and creating DuckDB views...") - initialization_successful = build_duckdb_from_existing_raw( - dataset_name=dataset_key, db_target_path=final_db_path + if not parquet_root or not parquet_root.exists(): + typer.secho( + f"Parquet directory not found at {parquet_root}.\n", + fg=typer.colors.RED, + err=True, ) + raise typer.Exit(code=1) + + initialization_successful = initialize_duckdb_from_parquet( + dataset_name=dataset_key, db_target_path=final_db_path + ) if not initialization_successful: typer.secho( @@ -177,7 +167,7 @@ def dataset_init_cmd( "Verifying database integrity..." ) - # Basic verification by querying a known table + # Basic verification by querying a known table (fast check) verification_table_name = dataset_config.get("primary_verification_table") if not verification_table_name: logger.warning( @@ -187,7 +177,7 @@ def dataset_init_cmd( typer.secho( ( f"Dataset '{dataset_name}' initialized to {final_db_path}. " - f"Raw files at {raw_files_storage_path.resolve()}." + f"Parquet at {parquet_root}." ), fg=typer.colors.GREEN, ) @@ -198,13 +188,13 @@ def dataset_init_cmd( return try: - record_count = verify_table_rowcount(engine, final_db_path, verification_table_name) + record_count = verify_table_rowcount(final_db_path, verification_table_name) typer.secho( f"Database verification successful: Found {record_count} records in table '{verification_table_name}'.", fg=typer.colors.GREEN, ) typer.secho( - f"Dataset '{dataset_name}' ready at {final_db_path}. Raw files at {raw_files_storage_path.resolve()}.", + f"Dataset '{dataset_name}' ready at {final_db_path}. Parquet at {parquet_root}.", fg=typer.colors.BRIGHT_GREEN, ) except Exception as e: @@ -216,6 +206,69 @@ def dataset_init_cmd( fg=typer.colors.RED, err=True, ) + # Set active dataset to match init target + if dataset_key == "mimic-iv-demo": + set_active_dataset("demo") + elif dataset_key == "mimic-iv-full": + set_active_dataset("full") + + +@app.command("use") +def use_cmd( + target: Annotated[ + str, + typer.Argument(help="Select active dataset: demo | full | bigquery", metavar="TARGET"), + ] +): + """Set the active dataset selection for the project.""" + target = target.lower() + if target not in ("demo", "full", "bigquery"): + typer.secho("Target must be one of: demo, full, bigquery", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + + if target in ("demo", "full"): + availability = detect_available_local_datasets()[target] + if not availability["parquet_present"]: + typer.secho( + f"Parquet directory missing at {availability['parquet_root']}. Cannot activate '{target}'.", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=1) + + set_active_dataset(target) + typer.secho(f"Active dataset set to '{target}'.", fg=typer.colors.GREEN) + + +@app.command("status") +def status_cmd(): + """Show active dataset, local DB path, Parquet presence, quick counts and sizes.""" + active = get_active_dataset() or "(unset)" + typer.echo(f"Active dataset: {active}") + + availability = detect_available_local_datasets() + + for label in ("demo", "full"): + info = availability[label] + typer.echo( + f"{label}: parquet_present={info['parquet_present']} db_present={info['db_present']}\n parquet_root={info['parquet_root']}\n db_path={info['db_path']}" + ) + if info["parquet_present"]: + try: + size_bytes = compute_parquet_dir_size(Path(info["parquet_root"])) + typer.echo(f" parquet_size_bytes={size_bytes}") + except Exception: + typer.echo(" parquet_size_bytes=(skipped)") + + # Try a quick rowcount on the verification table if db present + ds_name = "mimic-iv-demo" if label == "demo" else "mimic-iv-full" + cfg = get_dataset_config(ds_name) + if info["db_present"] and cfg: + try: + count = verify_table_rowcount(Path(info["db_path"]), cfg["primary_verification_table"]) + typer.echo(f" {cfg['primary_verification_table']}_rowcount={count}") + except Exception: + typer.echo(" rowcount=(skipped)") @app.command("config") @@ -232,15 +285,15 @@ def config_cmd( typer.Option( "--backend", "-b", - help="Backend to use (sqlite, duckdb, or bigquery). Default: sqlite", + help="Backend to use (duckdb or bigquery). Default: duckdb", ), - ] = "sqlite", + ] = "duckdb", db_path: Annotated[ str | None, typer.Option( "--db-path", "-p", - help="Path to SQLite database (for sqlite or duckdb backend)", + help="Path to DuckDB database (for duckdb backend)", ), ] = None, project_id: Annotated[ @@ -314,11 +367,6 @@ def config_cmd( raise typer.Exit(code=1) # Validate backend-specific arguments - # sqlite: db_path allowed, project_id not allowed - if backend == "sqlite" and project_id: - typer.secho("❌ Error: --project-id can only be used with --backend bigquery", fg=typer.colors.RED, err=True) - raise typer.Exit(code=1) - # duckdb: db_path allowed, project_id not allowed if backend == "duckdb" and project_id: typer.secho("❌ Error: --project-id can only be used with --backend bigquery", fg=typer.colors.RED, err=True) @@ -326,7 +374,7 @@ def config_cmd( # bigquery: requires project_id, db_path not allowed if backend == "bigquery" and db_path: - typer.secho("❌ Error: --db-path can only be used with --backend sqlite or duckdb", fg=typer.colors.RED, err=True) + typer.secho("❌ Error: --db-path can only be used with --backend duckdb", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) if backend == "bigquery" and not project_id: typer.secho("❌ Error: --project-id is required when using --backend bigquery", fg=typer.colors.RED, err=True) @@ -347,10 +395,10 @@ def config_cmd( # Build command arguments cmd = [sys.executable, str(script_path)] - if backend != "sqlite": + if backend != "duckdb": cmd.extend(["--backend", backend]) - if backend in ("sqlite", "duckdb") and db_path: + if backend == "duckdb" and db_path: cmd.extend(["--db-path", db_path]) elif backend == "bigquery" and project_id: cmd.extend(["--project-id", project_id]) @@ -394,7 +442,7 @@ def config_cmd( if quick: cmd.append("--quick") - if backend != "sqlite": + if backend != "duckdb": cmd.extend(["--backend", backend]) if server_name != "m3": @@ -406,7 +454,7 @@ def config_cmd( if working_directory: cmd.extend(["--working-directory", working_directory]) - if backend in ("sqlite", "duckdb") and db_path: + if backend == "duckdb" and db_path: cmd.extend(["--db-path", db_path]) elif backend == "bigquery" and project_id: cmd.extend(["--project-id", project_id]) @@ -441,5 +489,158 @@ def config_cmd( raise typer.Exit(code=1) +@app.command("download") +def download_cmd( + dataset: Annotated[ + str, + typer.Argument( + help=( + "Dataset to download (currently only supports 'mimic-iv-demo'). " + f"Configured: {', '.join(SUPPORTED_DATASETS.keys())}" + ), + metavar="DATASET_NAME", + ), + ], + output: Annotated[ + str | None, + typer.Option( + "--output", + "-o", + help=( + "Directory to store downloaded files. Default: /m3_data/raw_files//" + ), + ), + ] = None, +): + """ + Download public dataset files (demo only in this version). + + - For 'mimic-iv-demo', downloads CSV.gz files under hosp/ and icu/ subdirectories. + - Files are saved to the output directory, preserving the original structure. + - This command does not convert CSV to Parquet. + """ + dataset_key = dataset.lower() + if dataset_key != "mimic-iv-demo": + typer.secho( + "Currently only 'mimic-iv-demo' is supported by 'm3 download'.", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=1) + + cfg = get_dataset_config(dataset_key) + if not cfg or not cfg.get("file_listing_url"): + typer.secho( + f"Dataset '{dataset}' is not configured for download.", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=1) + + # Default output: /m3_data/raw_files// + if output: + out_dir = Path(output).resolve() + else: + # Build from the parquet root: /m3_data/parquet/ -> /m3_data/raw_files/ + pq = get_dataset_parquet_root(dataset_key) + out_dir = pq.parent.parent / "raw_files" / dataset_key + + out_dir.mkdir(parents=True, exist_ok=True) + + typer.echo(f"Downloading dataset: '{dataset}'") + typer.echo(f"Listing URL: {cfg.get('file_listing_url')}") + typer.echo(f"Output directory: {out_dir}") + + ok = download_dataset(dataset_key, out_dir) + if not ok: + typer.secho( + "Download failed. Please check logs for details.", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=1) + + typer.secho("✅ Download complete.", fg=typer.colors.GREEN) + typer.secho( + "Next step: Run 'm3 convert' to convert the downloaded CSV files to Parquet.", + fg=typer.colors.YELLOW, + ) + + +@app.command("convert") +def convert_cmd( + dataset: Annotated[ + str, + typer.Argument( + help=( + "Dataset to convert (csv.gz → parquet). Expected CSVs under raw_files/." + ), + metavar="DATASET_NAME", + ), + ], + src: Annotated[ + str | None, + typer.Option( + "--src", + "-s", + help="Root directory containing CSV.gz files (default: /m3_data/raw_files/)", + ), + ] = None, + dst: Annotated[ + str | None, + typer.Option( + "--dst", + "-d", + help="Destination Parquet root (default: /m3_data/parquet/)", + ), + ] = None, +): + """ + Convert all CSV.gz files for a dataset to Parquet, mirroring structure (hosp/, icu/). + Uses DuckDB streaming COPY for low memory usage. + """ + dataset_key = dataset.lower() + cfg = get_dataset_config(dataset_key) + if not cfg: + typer.secho( + f"Unsupported dataset: {dataset}", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=1) + + # Defaults + pq_root_default = get_dataset_parquet_root(dataset_key) + # raw_files default relative to project root + if pq_root_default is None: + typer.secho("Could not determine dataset directories.", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + + if src: + csv_root = Path(src).resolve() + else: + csv_root = pq_root_default.parent.parent / "raw_files" / dataset_key + + if dst: + parquet_root = Path(dst).resolve() + else: + parquet_root = pq_root_default + + typer.echo(f"Converting dataset: '{dataset}'") + typer.echo(f"CSV root: {csv_root}") + typer.echo(f"Parquet destination: {parquet_root}") + + ok = convert_csv_to_parquet(dataset_key, csv_root, parquet_root) + if not ok: + typer.secho( + "Conversion failed. Please check logs for details.", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=1) + + typer.secho("✅ Conversion complete.", fg=typer.colors.GREEN) + + if __name__ == "__main__": app() diff --git a/src/m3/config.py b/src/m3/config.py index f04e32e..7c1860a 100644 --- a/src/m3/config.py +++ b/src/m3/config.py @@ -1,3 +1,4 @@ +import json import logging from pathlib import Path from typing import Literal @@ -36,7 +37,8 @@ def _get_project_root() -> Path: _PROJECT_DATA_DIR = _PROJECT_ROOT / "m3_data" DEFAULT_DATABASES_DIR = _PROJECT_DATA_DIR / "databases" -DEFAULT_RAW_FILES_DIR = _PROJECT_DATA_DIR / "raw_files" +DEFAULT_PARQUET_DIR = _PROJECT_DATA_DIR / "parquet" +RUNTIME_CONFIG_PATH = _PROJECT_DATA_DIR / "config.json" # -------------------------------------------------- @@ -59,6 +61,12 @@ def _get_project_root() -> Path: }, } +# Dataset name aliases used on the CLI +CLI_DATASET_ALIASES = { + "demo": "mimic-iv-demo", + "full": "mimic-iv-full", +} + # -------------------------------------------------- # Helper functions @@ -68,36 +76,135 @@ def get_dataset_config(dataset_name: str) -> dict | None: return SUPPORTED_DATASETS.get(dataset_name.lower()) -def get_default_database_path(dataset_name: str, engine: Literal["sqlite", "duckdb"] = "sqlite") -> Path | None: +def get_default_database_path(dataset_name: str, engine: Literal["duckdb", "bigquery"] = "duckdb") -> Path | None: """ - Return the default local DB path for a given dataset and engine, + Return the default local DuckDB path for a given dataset, under /m3_data/databases/. """ - cfg = get_dataset_config(dataset_name) + if engine != "duckdb": + logger.warning("Only DuckDB is supported for local databases.") + return None + cfg = get_dataset_config(dataset_name) if not cfg: logger.warning(f"Unknown dataset, cannot determine default DB path: {dataset_name}") return None DEFAULT_DATABASES_DIR.mkdir(parents=True, exist_ok=True) - - db_fname = cfg.get("default_db_filename") if engine == "sqlite" else cfg.get("default_duckdb_filename") + db_fname = cfg.get("default_duckdb_filename") if not db_fname: - logger.warning(f"Missing default filename for dataset: {dataset_name}") + logger.warning(f"Missing default DuckDB filename for dataset: {dataset_name}") return None - return DEFAULT_DATABASES_DIR / db_fname -def get_dataset_raw_files_path(dataset_name: str) -> Path | None: +def get_dataset_parquet_root(dataset_name: str) -> Path | None: """ - Return the raw-file storage path for a dataset, - under /m3_data/raw_files//. + Return the Parquet root for a dataset under + /m3_data/parquet//. """ cfg = get_dataset_config(dataset_name) - if cfg: - path = DEFAULT_RAW_FILES_DIR / dataset_name.lower() - path.mkdir(parents=True, exist_ok=True) - return path + if not cfg: + logger.warning(f"Unknown dataset, cannot determine Parquet root: {dataset_name}") + return None + path = DEFAULT_PARQUET_DIR / dataset_name.lower() + path.mkdir(parents=True, exist_ok=True) + return path - logger.warning(f"Unknown dataset, cannot determine raw path: {dataset_name}") + +# ----------------------------- +# Runtime config (active dataset) +# ----------------------------- +def _ensure_data_dirs(): + DEFAULT_DATABASES_DIR.mkdir(parents=True, exist_ok=True) + DEFAULT_PARQUET_DIR.mkdir(parents=True, exist_ok=True) + _PROJECT_DATA_DIR.mkdir(parents=True, exist_ok=True) + + +def get_runtime_config_path() -> Path: + return RUNTIME_CONFIG_PATH + + +def load_runtime_config() -> dict: + """Load runtime configuration from /m3_data/config.json.""" + _ensure_data_dirs() + if RUNTIME_CONFIG_PATH.exists(): + try: + return json.loads(RUNTIME_CONFIG_PATH.read_text()) + except Exception: + logger.warning("Could not parse runtime config; using defaults") + # defaults + return { + "active_dataset": None, + "duckdb_paths": { + "demo": str(get_default_database_path("mimic-iv-demo") or ""), + "full": str(get_default_database_path("mimic-iv-full") or ""), + }, + "parquet_roots": { + "demo": str(get_dataset_parquet_root("mimic-iv-demo") or ""), + "full": str(get_dataset_parquet_root("mimic-iv-full") or ""), + }, + } + + +def save_runtime_config(cfg: dict) -> None: + _ensure_data_dirs() + RUNTIME_CONFIG_PATH.write_text(json.dumps(cfg, indent=2)) + + +def _has_parquet_files(path: Path | None) -> bool: + return bool(path and path.exists() and any(path.rglob("*.parquet"))) + + +def detect_available_local_datasets() -> dict: + """Return presence flags for demo/full based on Parquet roots and DuckDB files.""" + cfg = load_runtime_config() + demo_parquet = Path(cfg["parquet_roots"]["demo"]) if cfg["parquet_roots"]["demo"] else get_dataset_parquet_root("mimic-iv-demo") + full_parquet = Path(cfg["parquet_roots"]["full"]) if cfg["parquet_roots"]["full"] else get_dataset_parquet_root("mimic-iv-full") + demo_db = Path(cfg["duckdb_paths"]["demo"]) if cfg["duckdb_paths"]["demo"] else get_default_database_path("mimic-iv-demo") + full_db = Path(cfg["duckdb_paths"]["full"]) if cfg["duckdb_paths"]["full"] else get_default_database_path("mimic-iv-full") + return { + "demo": { + "parquet_present": _has_parquet_files(demo_parquet), + "db_present": bool(demo_db and demo_db.exists()), + "parquet_root": str(demo_parquet) if demo_parquet else "", + "db_path": str(demo_db) if demo_db else "", + }, + "full": { + "parquet_present": _has_parquet_files(full_parquet), + "db_present": bool(full_db and full_db.exists()), + "parquet_root": str(full_parquet) if full_parquet else "", + "db_path": str(full_db) if full_db else "", + }, + } + + +def get_active_dataset() -> str | None: + cfg = load_runtime_config() + active = cfg.get("active_dataset") + if active: + return active + # Auto-detect default: prefer demo, then full + availability = detect_available_local_datasets() + if availability["demo"]["parquet_present"]: + return "demo" + if availability["full"]["parquet_present"]: + return "full" return None + + +def set_active_dataset(choice: str) -> None: + if choice not in ("demo", "full", "bigquery"): + raise ValueError("active_dataset must be one of: demo, full, bigquery") + cfg = load_runtime_config() + cfg["active_dataset"] = choice + save_runtime_config(cfg) + + +def get_duckdb_path_for(choice: str) -> Path | None: + key = "mimic-iv-demo" if choice == "demo" else "mimic-iv-full" + return get_default_database_path(key) if choice in ("demo", "full") else None + + +def get_parquet_root_for(choice: str) -> Path | None: + key = "mimic-iv-demo" if choice == "demo" else "mimic-iv-full" + return get_dataset_parquet_root(key) if choice in ("demo", "full") else None diff --git a/src/m3/data_io.py b/src/m3/data_io.py index 2e26713..0333cc6 100644 --- a/src/m3/data_io.py +++ b/src/m3/data_io.py @@ -1,17 +1,25 @@ +import os +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import timedelta from pathlib import Path from urllib.parse import urljoin, urlparse -from typing import Literal -import polars as pl + +import duckdb import requests import typer from bs4 import BeautifulSoup -import duckdb -import time -from datetime import timedelta -from concurrent.futures import ThreadPoolExecutor, as_completed -import os -from m3.config import get_dataset_config, get_dataset_raw_files_path, logger +from m3.config import ( + get_dataset_config, + get_dataset_parquet_root, + get_default_database_path, + logger, +) + +######################################################## +# Download functionality +######################################################## COMMON_USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " @@ -181,166 +189,29 @@ def _download_dataset_files( return downloaded_count == len(unique_files_to_process) -def _load_csv_with_robust_parsing(csv_file_path: Path, table_name: str) -> pl.DataFrame: +def download_dataset(dataset_name: str, output_root: Path) -> bool: """ - Load a CSV file with proper type inference by scanning the entire file. + Public wrapper to download a supported dataset's CSV files. + - Currently intended for 'mimic-iv-demo' (public demo); extendable for others. + - Downloads into output_root preserving subdirectory structure (e.g., hosp/, icu/). """ - df = pl.read_csv( - source=csv_file_path, - infer_schema_length=None, # Scan entire file for proper type inference - try_parse_dates=True, - ignore_errors=False, - null_values=["", "NULL", "null", "\\N", "NA"], - ) - - # Log empty columns (this is normal, not an error) - if df.height > 0: - empty_columns = [col for col in df.columns if df[col].is_null().all()] - if empty_columns: - logger.info( - f" Table '{table_name}': Found {len(empty_columns)} empty column(s): " - f"{', '.join(empty_columns[:5])}" - + ( - f" (and {len(empty_columns) - 5} more)" - if len(empty_columns) > 5 - else "" - ) - ) - - return df - - -def _etl_csv_collection_to_sqlite(csv_source_dir: Path, db_target_path: Path) -> bool: - """Loads all .csv.gz files from a directory structure into an SQLite database.""" - db_target_path.parent.mkdir(parents=True, exist_ok=True) - # Polars uses this format for SQLite connections - db_connection_uri = f"sqlite:///{db_target_path.resolve()}" - logger.info( - f"Starting ETL: loading CSVs from '{csv_source_dir}' to SQLite DB " - f"at '{db_target_path}'" - ) - - csv_file_paths = list(csv_source_dir.rglob("*.csv.gz")) - if not csv_file_paths: - logger.error( - "ETL Error: No .csv.gz files found (recursively) in source directory: " - f"{csv_source_dir}" - ) + cfg = get_dataset_config(dataset_name) + if not cfg: + logger.error(f"Unsupported dataset: {dataset_name}") return False - - successfully_loaded_count = 0 - files_with_errors = [] - logger.info(f"Found {len(csv_file_paths)} .csv.gz files for ETL process.") - - for i, csv_file_path in enumerate(csv_file_paths): - # Generate table name from file path relative to the source directory - # e.g., source_dir/hosp/admissions.csv.gz -> hosp_admissions - relative_path = csv_file_path.relative_to(csv_source_dir) - table_name_parts = [part.lower() for part in relative_path.parts] - table_name = ( - "_".join(table_name_parts) - .replace(".csv.gz", "") - .replace("-", "_") - .replace(".", "_") - ) - - logger.info( - f"[{i + 1}/{len(csv_file_paths)}] ETL: Processing '{relative_path}' " - f"into SQLite table '{table_name}'..." - ) - - try: - # Use the robust parsing function - df = _load_csv_with_robust_parsing(csv_file_path, table_name) - - df.write_database( - table_name=table_name, - connection=db_connection_uri, - if_table_exists="replace", # Overwrite table if it exists - engine="sqlalchemy", # Recommended engine for Polars with SQLite - ) - logger.info( - f" Successfully loaded '{relative_path}' into table '{table_name}' " - f"({df.height} rows, {df.width} columns)." - ) - successfully_loaded_count += 1 - - except Exception as e: - err_msg = ( - f"Unexpected error during ETL for '{relative_path}' " - f"(target table '{table_name}'): {e}" - ) - logger.error(err_msg, exc_info=True) - files_with_errors.append(f"{relative_path}: {e!s}") - # Continue to process other files even if one fails - - if files_with_errors: - logger.warning( - "ETL completed with errors during processing for " - f"{len(files_with_errors)} file(s):" - ) - for detail in files_with_errors: - logger.warning(f" - {detail}") - - # Strict success: all found files must be loaded without Polars/DB errors. - if successfully_loaded_count == len(csv_file_paths): - logger.info( - f"All {len(csv_file_paths)} CSV files successfully processed & loaded into " - f"{db_target_path}." - ) - return True - elif successfully_loaded_count > 0: - logger.warning( - f"Partially completed ETL: Loaded {successfully_loaded_count} out of " - f"{len(csv_file_paths)} files. Some files encountered errors during " - "their individual processing and were not loaded." - ) - return False - else: # No files were successfully loaded - logger.error( - "ETL process failed: No CSV files were successfully loaded into the " - f"database from {csv_source_dir}." - ) - return False - - -def initialize_dataset(dataset_name: str, db_target_path: Path) -> bool: - """Initializes a dataset: downloads files and loads them into a database.""" - dataset_config = get_dataset_config(dataset_name) - if not dataset_config: - logger.error(f"Configuration for dataset '{dataset_name}' not found.") - return False - - raw_files_root_dir = get_dataset_raw_files_path(dataset_name) - raw_files_root_dir.mkdir(parents=True, exist_ok=True) - - logger.info(f"Starting initialization for dataset: {dataset_name}") - download_ok = _download_dataset_files( - dataset_name, dataset_config, raw_files_root_dir - ) - - if not download_ok: + if not cfg.get("file_listing_url"): logger.error( - f"Download phase failed for dataset '{dataset_name}'. ETL skipped." + f"Dataset '{dataset_name}' does not have a configured listing URL. " + "This version only supports public demo download." ) return False - logger.info(f"Download phase complete for '{dataset_name}'. Starting ETL phase.") - etl_ok = _etl_csv_collection_to_sqlite(raw_files_root_dir, db_target_path) - - if not etl_ok: - logger.error(f"ETL phase failed for dataset '{dataset_name}'.") - return False - - logger.info( - f"Dataset '{dataset_name}' successfully initialized. " - f"Database at: {db_target_path}" - ) - return True + output_root.mkdir(parents=True, exist_ok=True) + return _download_dataset_files(dataset_name, cfg, output_root) ######################################################## -# DuckDB functions +# CSV to Parquet conversion ######################################################## def _csv_to_parquet_all(src_root: Path, parquet_root: Path) -> bool: @@ -395,32 +266,28 @@ def _convert_one(csv_gz: Path) -> tuple[Path | None, float]: con.close() start_time = time.time() - cpu_cnt = os.cpu_count() or 4 - # Increase default to 2 for better throughput max_workers = max(1, int(os.environ.get("M3_CONVERT_MAX_WORKERS", "4"))) - + total_files = len(csv_files) completed = 0 - + with ThreadPoolExecutor(max_workers=max_workers) as ex: futures = {ex.submit(_convert_one, f): f for f in csv_files} - - # Manual progress tracking for better time estimates + logger.info(f"Converting {total_files} CSV files to Parquet using {max_workers} workers...") - + for fut in as_completed(futures): try: - result_path, file_time = fut.result() + result_path, _ = fut.result() if result_path is not None: parquet_paths.append(result_path) completed += 1 - - # Log elapsed time + elapsed = time.time() - start_time logger.info( f"Progress: {completed}/{total_files} files " f"({100*completed/total_files:.1f}%) - " - f"Elapsed: {str(timedelta(seconds=int(elapsed)))} - " + f"Elapsed: {timedelta(seconds=int(elapsed))!s}" ) except Exception as e: csv_file = futures[fut] @@ -430,17 +297,59 @@ def _convert_one(csv_gz: Path) -> tuple[Path | None, float]: elapsed_time = time.time() - start_time logger.info( - f"✓ Converted {len(parquet_paths)} files to Parquet under {parquet_root} " - f"in {str(timedelta(seconds=int(elapsed_time)))}" + f"\u2713 Converted {len(parquet_paths)} files to Parquet under {parquet_root} " + f"in {timedelta(seconds=int(elapsed_time))!s}" ) return True +def convert_csv_to_parquet(dataset_name: str, csv_root: Path, parquet_root: Path) -> bool: + """ + Public wrapper to convert CSV.gz files to Parquet for a dataset. + - csv_root: root folder containing hosp/ and icu/ CSV.gz files + - parquet_root: destination root for Parquet files mirroring structure + """ + if not csv_root.exists(): + logger.error(f"CSV root not found: {csv_root}") + return False + parquet_root.mkdir(parents=True, exist_ok=True) + return _csv_to_parquet_all(csv_root, parquet_root) + +######################################################## +# DuckDB functions +######################################################## + +def initialize_duckdb_from_parquet(dataset_name: str, db_target_path: Path) -> bool: + """ + Initialize or refresh a DuckDB for the dataset by creating views over Parquet. + + Parquet root must exist under: + /m3_data/parquet// + """ + dataset_config = get_dataset_config(dataset_name) + if not dataset_config: + logger.error(f"Configuration for dataset '{dataset_name}' not found.") + return False + + parquet_root = get_dataset_parquet_root(dataset_name) + if not parquet_root or not parquet_root.exists(): + logger.error( + f"Missing Parquet directory for '{dataset_name}' at {parquet_root}. " + "Place Parquet files under the expected path or run the future download command." + ) + return False + + logger.info( + f"Creating or refreshing views in {db_target_path} for Parquet under {parquet_root}" + ) + return _create_duckdb_with_views(db_target_path, parquet_root) + + def _create_duckdb_with_views(db_path: Path, parquet_root: Path) -> bool: """ Create a DuckDB database and define one view per Parquet file, using the proper table naming structure that matches MIMIC-IV expectations. - + For example: - hosp/admissions.parquet → view: hosp_admissions - icu/chartevents.parquet → view: icu_chartevents @@ -457,35 +366,35 @@ def _create_duckdb_with_views(db_path: Path, parquet_root: Path) -> bool: cpu_count = os.cpu_count() or 4 con.execute(f"PRAGMA threads={cpu_count}") con.execute("SET memory_limit='8GB'") # adjust to your machine - + logger.info(f"Creating {len(parquet_files)} views in DuckDB...") start_time = time.time() created = 0 - + for idx, pq in enumerate(parquet_files, 1): # Get relative path from parquet_root rel = pq.relative_to(parquet_root) - + # Build view name from directory structure + filename # e.g., hosp/admissions.parquet -> hosp_admissions parts = list(rel.parent.parts) + [rel.stem] # stem removes .parquet - + # Clean and join parts view_name = "_".join( - p.lower().replace("-", "_").replace(".", "_") + p.lower().replace("-", "_").replace(".", "_") for p in parts if p != "." ) - + # Create view pointing to the specific parquet file sql = f""" CREATE OR REPLACE VIEW {view_name} AS SELECT * FROM read_parquet('{pq.as_posix()}'); """ - + try: con.execute(sql) created += 1 - + # Progress logging if idx % 5 == 0 or idx == len(parquet_files): elapsed = time.time() - start_time @@ -495,7 +404,7 @@ def _create_duckdb_with_views(db_path: Path, parquet_root: Path) -> bool: f"Progress: {idx}/{len(parquet_files)} views " f"({100*idx/len(parquet_files):.1f}%) - " f"Last: {view_name} - " - f"ETA: {str(timedelta(seconds=int(eta_seconds)))}" + f"ETA: {timedelta(seconds=int(eta_seconds))!s}" ) except Exception as e: logger.error(f"Failed to create view {view_name} from {pq}: {e}") @@ -505,72 +414,69 @@ def _create_duckdb_with_views(db_path: Path, parquet_root: Path) -> bool: elapsed_time = time.time() - start_time logger.info( f"✓ Created {created} views in {db_path} in " - f"{str(timedelta(seconds=int(elapsed_time)))}" + f"{timedelta(seconds=int(elapsed_time))!s}" ) - + # List all created views for verification - views_result = con.execute("SELECT name FROM sqlite_master WHERE type='view' ORDER BY name").fetchall() - logger.info(f"Created views: {', '.join(v[0] for v in views_result[:10])}{'...' if len(views_result) > 10 else ''}") - + views_result = con.execute( + "SELECT table_name FROM information_schema.tables WHERE table_type='VIEW' ORDER BY table_name" + ).fetchall() + logger.info( + f"Created views: {', '.join(v[0] for v in views_result[:10])}{'...' if len(views_result) > 10 else ''}" + ) + return True finally: con.close() def build_duckdb_from_existing_raw(dataset_name: str, db_target_path: Path) -> bool: - """ - Build a DuckDB database from existing raw CSVs (no downloads). - - Converts CSVs under m3_data/raw_files// to Parquet - - Creates views in DuckDB that point to those Parquet files - """ - raw_files_root_dir = get_dataset_raw_files_path(dataset_name) - if not raw_files_root_dir or not raw_files_root_dir.exists(): + """Deprecated. Use initialize_duckdb_from_parquet with Parquet already available.""" + parquet_root = get_dataset_parquet_root(dataset_name) + if not parquet_root or not parquet_root.exists(): logger.error( - f"Raw files directory not found for dataset '{dataset_name}'. " - "Run 'm3 init mimic-iv-demo' first for the demo, or place full data under " - f"{(raw_files_root_dir or Path()).resolve()}." + f"Parquet directory not found for dataset '{dataset_name}'. Expected at /m3_data/parquet/{dataset_name}/" ) return False + return _create_duckdb_with_views(db_target_path, parquet_root) - parquet_root = raw_files_root_dir.parent / "parquet" / dataset_name - parquet_root.mkdir(parents=True, exist_ok=True) - if not _csv_to_parquet_all(raw_files_root_dir, parquet_root): - return False +######################################################## +# Verification and utilities +######################################################## - logger.info("✓ Created all parquet files") +def verify_table_rowcount(db_path: Path, table_name: str) -> int: + con = duckdb.connect(str(db_path)) + try: + row = con.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone() + if row is None: + raise RuntimeError("No result") + return int(row[0]) + finally: + con.close() - return _create_duckdb_with_views(db_target_path, parquet_root) +def ensure_duckdb_for_dataset(dataset_key: str) -> tuple[bool, Path | None, Path | None]: + """ + Ensure DuckDB exists and views are created for the dataset ('mimic-iv-demo'|'mimic-iv-full'). + Returns (ok, db_path, parquet_root). + """ + db_path = get_default_database_path(dataset_key) + parquet_root = get_dataset_parquet_root(dataset_key) + if not parquet_root or not parquet_root.exists(): + logger.error( + f"Parquet directory missing: {parquet_root}. Expected at /m3_data/parquet/{dataset_key}/" + ) + return False, db_path, parquet_root + ok = _create_duckdb_with_views(db_path, parquet_root) + return ok, db_path, parquet_root -######################################################## -# Verification functions -######################################################## -def verify_table_rowcount( - engine: Literal["sqlite", "duckdb"], - db_path: Path, - table_name: str, -) -> int: - if engine == "sqlite": - import sqlite3 - conn = sqlite3.connect(db_path) +def compute_parquet_dir_size(parquet_root: Path) -> int: + total = 0 + for p in parquet_root.rglob("*.parquet"): try: - cur = conn.cursor() - cur.execute(f"SELECT COUNT(*) FROM {table_name}") - row = cur.fetchone() - if row is None: - raise sqlite3.Error("No result") - return int(row[0]) - finally: - conn.close() - else: # duckdb - import duckdb - con = duckdb.connect(str(db_path)) - try: - row = con.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone() - if row is None: - raise RuntimeError("No result") - return int(row[0]) - finally: - con.close() + total += p.stat().st_size + except OSError: + pass + return total diff --git a/src/m3/mcp_client_configs/dynamic_mcp_config.py b/src/m3/mcp_client_configs/dynamic_mcp_config.py index adb0a07..5586a7b 100644 --- a/src/m3/mcp_client_configs/dynamic_mcp_config.py +++ b/src/m3/mcp_client_configs/dynamic_mcp_config.py @@ -52,7 +52,7 @@ def generate_config( server_name: str = "m3", python_path: str | None = None, working_directory: str | None = None, - backend: str = "sqlite", + backend: str = "duckdb", db_path: str | None = None, project_id: str | None = None, additional_env: dict[str, str] | None = None, @@ -157,39 +157,22 @@ def interactive_config(self) -> dict[str, Any]: # Backend selection - simplified print("\nChoose backend:") - print("1. SQLite (local database)") - print("2. DuckDB (local database)") - print("3. BigQuery (Google Cloud)") + print("1. DuckDB (local database)") + print("2. BigQuery (Google Cloud)") while True: backend_choice = input("Choose backend [1]: ").strip() or "1" - if backend_choice in ["1", "2", "3"]: + if backend_choice in ["1", "2"]: break - print("Please enter 1, 2, or 3") + print("Please enter 1 or 2") - backend = "sqlite" if backend_choice == "1" else "duckdb" if backend_choice == "2" else "bigquery" + backend = "duckdb" if backend_choice == "1" else "bigquery" # Backend-specific configuration db_path = None project_id = None - if backend == "sqlite": - print("\n📁 SQLite Configuration:") - from m3.config import get_default_database_path - - default_db_path = get_default_database_path("mimic-iv-demo") - if default_db_path is None: - raise ValueError(_DATABASE_PATH_ERROR_MSG) - print(f"Default database path: {default_db_path}") - - db_path = ( - input( - "SQLite database path (optional, press Enter to use default): " - ).strip() - or None - ) - - elif backend == "duckdb": + if backend == "duckdb": print("\n📁 DuckDB Configuration:") from m3.config import get_default_database_path @@ -298,7 +281,7 @@ def print_config_info(config: dict[str, Any]): if "M3_DB_PATH" in server_config["env"]: print(f"💾 Database path: {server_config['env']['M3_DB_PATH']}") - elif server_config["env"].get("M3_BACKEND") in ("sqlite", "duckdb"): + elif server_config["env"].get("M3_BACKEND") in ("duckdb",): # Show the default path when using SQLite or DuckDB backend from m3.config import get_default_database_path engine = server_config["env"].get("M3_BACKEND") @@ -365,12 +348,12 @@ def main(): parser.add_argument("--working-directory", help="Working directory for the server") parser.add_argument( "--backend", - choices=["sqlite", "duckdb", "bigquery"], - default="sqlite", - help="Backend to use (default: sqlite)", + choices=["duckdb", "bigquery"], + default="duckdb", + help="Backend to use (default: duckdb)", ) parser.add_argument( - "--db-path", help="Path to SQLite or DuckDB database (for sqlite or duckdb backend)" + "--db-path", help="Path to DuckDB database (for duckdb backend)" ) parser.add_argument( "--project-id", help="Google Cloud project ID (for bigquery backend)" @@ -393,7 +376,7 @@ def main(): args = parser.parse_args() # Validate backend-specific arguments - if args.backend in ("sqlite", "duckdb") and args.project_id: + if args.backend in ("duckdb",) and args.project_id: print( "❌ Error: --project-id can only be used with --backend bigquery", file=sys.stderr, @@ -402,7 +385,7 @@ def main(): if args.backend == "bigquery" and args.db_path: print( - "❌ Error: --db-path can only be used with --backend sqlite or duckdb", + "❌ Error: --db-path can only be used with --backend duckdb", file=sys.stderr, ) sys.exit(1) diff --git a/src/m3/mcp_client_configs/setup_claude_desktop.py b/src/m3/mcp_client_configs/setup_claude_desktop.py index 77594ab..5fcd244 100644 --- a/src/m3/mcp_client_configs/setup_claude_desktop.py +++ b/src/m3/mcp_client_configs/setup_claude_desktop.py @@ -60,7 +60,7 @@ def get_python_path(): def create_mcp_config( - backend="sqlite", + backend="duckdb", db_path=None, project_id=None, oauth2_enabled=False, @@ -82,7 +82,7 @@ def create_mcp_config( } # Add backend-specific environment variables - if backend in ("sqlite", "duckdb") and db_path: + if backend == "duckdb" and db_path: config["mcpServers"]["m3"]["env"]["M3_DB_PATH"] = db_path elif backend == "bigquery" and project_id: config["mcpServers"]["m3"]["env"]["M3_PROJECT_ID"] = project_id @@ -116,7 +116,7 @@ def create_mcp_config( def setup_claude_desktop( - backend="sqlite", + backend="duckdb", db_path=None, project_id=None, oauth2_enabled=False, @@ -162,10 +162,7 @@ def setup_claude_desktop( print(f"📁 Config file: {claude_config_path}") print(f"🔧 Backend: {backend}") - if backend == "sqlite": - db_path_display = db_path or "default (m3_data/databases/mimic_iv_demo.db)" - print(f"💾 Database: {db_path_display}") - elif backend == "duckdb": + if backend == "duckdb": db_path_display = db_path or "default (m3_data/databases/mimic_iv_demo.duckdb)" print(f"💾 Database: {db_path_display}") elif backend == "bigquery": @@ -207,12 +204,12 @@ def main(): ) parser.add_argument( "--backend", - choices=["sqlite", "duckdb", "bigquery"], - default="sqlite", - help="Backend to use (default: sqlite)", + choices=["duckdb", "bigquery"], + default="duckdb", + help="Backend to use (default: duckdb)", ) parser.add_argument( - "--db-path", help="Path to SQLite or DuckDB database (for sqlite or duckdb backend)" + "--db-path", help="Path to DuckDB database (for duckdb backend)" ) parser.add_argument( "--project-id", help="Google Cloud project ID (for bigquery backend)" @@ -233,12 +230,12 @@ def main(): args = parser.parse_args() # Validate backend-specific arguments - if args.backend in ("sqlite", "duckdb") and args.project_id: + if args.backend in ("duckdb",) and args.project_id: print("❌ Error: --project-id can only be used with --backend bigquery") exit(1) if args.backend == "bigquery" and args.db_path: - print("❌ Error: --db-path can only be used with --backend sqlite or duckdb") + print("❌ Error: --db-path can only be used with --backend duckdb") exit(1) # Require project_id for BigQuery backend diff --git a/src/m3/mcp_server.py b/src/m3/mcp_server.py index 0a0d47c..27538cf 100644 --- a/src/m3/mcp_server.py +++ b/src/m3/mcp_server.py @@ -1,14 +1,12 @@ """ M3 MCP Server - MIMIC-IV + MCP + Models -Provides MCP tools for querying MIMIC-IV data via SQLite or BigQuery. +Provides MCP tools for querying MIMIC-IV data via DuckDB (local) or BigQuery. """ import os -import sqlite3 -import duckdb from pathlib import Path -import pandas as pd +import duckdb import sqlparse from fastmcp import FastMCP @@ -138,22 +136,11 @@ def _init_backend(): # Initialize OAuth2 authentication init_oauth2() - _backend = os.getenv("M3_BACKEND", "sqlite") + _backend = os.getenv("M3_BACKEND", "duckdb") - if _backend == "sqlite": + if _backend == "duckdb": _db_path = os.getenv("M3_DB_PATH") if not _db_path: - # Use default database path - _db_path = get_default_database_path("mimic-iv-demo") - - # Ensure the database exists - if not Path(_db_path).exists(): - raise FileNotFoundError(f"SQLite database not found: {_db_path}") - - elif _backend == "duckdb": - _db_path = os.getenv("M3_DB_PATH") - if not _db_path: - from m3.config import get_default_database_path path = get_default_database_path("mimic-iv-demo", engine="duckdb") _db_path = str(path) if path else None if not _db_path or not Path(_db_path).exists(): @@ -185,9 +172,7 @@ def _init_backend(): def _get_backend_info() -> str: """Get current backend information for display in responses.""" - if _backend == "sqlite": - return f"🔧 **Current Backend:** SQLite (local database)\n📁 **Database Path:** {_db_path}\n" - elif _backend == "duckdb": + if _backend == "duckdb": return f"🔧 **Current Backend:** DuckDB (local database)\n📁 **Database Path:** {_db_path}\n" else: return f"🔧 **Current Backend:** BigQuery (cloud database)\n☁️ **Project ID:** {_project_id}\n" @@ -201,31 +186,6 @@ def _get_backend_info() -> str: # from calling other MCP tools, which violates the MCP protocol. -def _execute_sqlite_query(sql_query: str) -> str: - """Execute SQLite query - internal function.""" - try: - conn = sqlite3.connect(_db_path) - try: - df = pd.read_sql_query(sql_query, conn) - - if df.empty: - return "No results found" - - # Limit output size - if len(df) > 50: - result = df.head(50).to_string(index=False) - result += f"\n... ({len(df)} total rows, showing first 50)" - else: - result = df.to_string(index=False) - - return result - finally: - conn.close() - except Exception as e: - # Re-raise the exception so the calling function can handle it with enhanced guidance - raise e - - def _execute_duckdb_query(sql_query: str) -> str: """Execute DuckDB query - internal function.""" try: @@ -291,9 +251,7 @@ def _execute_query_internal(sql_query: str) -> str: return f"❌ **Security Error:** {message}\n\n💡 **Tip:** Only SELECT statements are allowed for data analysis." try: - if _backend == "sqlite": - return _execute_sqlite_query(sql_query) - elif _backend == "duckdb": + if _backend == "duckdb": return _execute_duckdb_query(sql_query) else: # bigquery return _execute_bigquery_query(sql_query) @@ -390,12 +348,7 @@ def get_database_schema() -> str: Returns: List of all available tables in the database with current backend info """ - if _backend == "sqlite": - query = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" - result = _execute_query_internal(query) - return f"{_get_backend_info()}\n📋 **Available Tables:**\n{result}" - - elif _backend == "duckdb": + if _backend == "duckdb": query = """ SELECT table_name FROM information_schema.tables @@ -405,8 +358,7 @@ def get_database_schema() -> str: result = _execute_query_internal(query) return f"{_get_backend_info()}\n📋 **Available Tables:**\n{result}" - - else: # bigquery + elif _backend == "bigquery": # Show fully qualified table names that are ready to copy-paste into queries query = """ SELECT CONCAT('`physionet-data.mimiciv_3_1_hosp.', table_name, '`') as query_ready_table_name @@ -443,12 +395,11 @@ def get_table_info(table_name: str, show_sample: bool = True) -> str: """ backend_info = _get_backend_info() - if _backend in ["sqlite", "duckdb"]: + if _backend == "duckdb": # Get column information pragma_query = f"PRAGMA table_info('{table_name}')" - execute_query_fn = _execute_sqlite_query if _backend == "sqlite" else _execute_duckdb_query try: - result = execute_query_fn(pragma_query) + result = _execute_duckdb_query(pragma_query) if "error" in result.lower(): return f"{backend_info}❌ Table '{table_name}' not found. Use get_database_schema() to see available tables." @@ -456,7 +407,7 @@ def get_table_info(table_name: str, show_sample: bool = True) -> str: if show_sample: sample_query = f"SELECT * FROM '{table_name}' LIMIT 3" - sample_result = execute_query_fn(sample_query) + sample_result = _execute_duckdb_query(sample_query) info_result += ( f"\n\n📊 **Sample Data (first 3 rows):**\n{sample_result}" ) @@ -600,7 +551,7 @@ def get_icu_stays(patient_id: int | None = None, limit: int = 10) -> str: return "Error: Invalid limit. Must be a positive integer between 1 and 10000." # Try common ICU table names based on backend - if _backend == "sqlite" or _backend == "duckdb": + if _backend == "duckdb": icustays_table = "icu_icustays" else: # bigquery icustays_table = "`physionet-data.mimiciv_3_1_icu.icustays`" @@ -650,7 +601,7 @@ def get_lab_results( return "Error: Invalid limit. Must be a positive integer between 1 and 10000." # Try common lab table names based on backend - if _backend == "sqlite" or _backend == "duckdb": + if _backend == "duckdb": labevents_table = "hosp_labevents" else: # bigquery labevents_table = "`physionet-data.mimiciv_3_1_hosp.labevents`" @@ -705,7 +656,7 @@ def get_race_distribution(limit: int = 10) -> str: return "Error: Invalid limit. Must be a positive integer between 1 and 10000." # Try common admissions table names based on backend - if _backend == "sqlite" or _backend == "duckdb": + if _backend == "duckdb": admissions_table = "hosp_admissions" else: # bigquery admissions_table = "`physionet-data.mimiciv_3_1_hosp.admissions`" diff --git a/tests/test_cli.py b/tests/test_cli.py index e6181cd..02eb32e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,3 @@ -import subprocess import tempfile from pathlib import Path from unittest.mock import MagicMock, patch @@ -41,56 +40,36 @@ def test_unknown_command_reports_error(): ) -@patch("m3.cli.initialize_dataset") -@patch("sqlite3.connect") -def test_init_command_respects_custom_db_path( - mock_sqlite_connect, mock_initialize_dataset -): - """Test that m3 init --db-path correctly uses custom database path override.""" - # Setup mocks - mock_initialize_dataset.return_value = True - - # Mock sqlite connection and cursor for verification query - mock_cursor = mock_sqlite_connect.return_value.cursor.return_value - mock_cursor.fetchone.return_value = (100,) # Mock row count result +@patch("m3.cli.initialize_duckdb_from_parquet") +@patch("m3.cli.verify_table_rowcount") +def test_init_command_duckdb_custom_path(mock_rowcount, mock_init): + """Test that m3 init --db-path uses custom database path override and DuckDB flow.""" + mock_init.return_value = True + mock_rowcount.return_value = 100 with tempfile.TemporaryDirectory() as temp_dir: - custom_db_path = Path(temp_dir) / "custom_mimic.db" - # Resolve the path to handle symlinks (like /var -> /private/var on macOS) + custom_db_path = Path(temp_dir) / "custom_mimic.duckdb" resolved_custom_db_path = custom_db_path.resolve() - # Run the init command with custom db path - result = runner.invoke( - app, ["init", "mimic-iv-demo", "--db-path", str(custom_db_path)] - ) + # Also ensure a dummy parquet path exists for the dataset discovery + with patch("m3.cli.get_dataset_parquet_root") as mock_parquet_root: + mock_parquet_root.return_value = Path("/Users/hannesill/Developer/m3/m3_data/parquet/mimic-iv-demo/") + with patch.object(Path, "exists", return_value=True): + result = runner.invoke( + app, ["init", "mimic-iv-demo", "--db-path", str(custom_db_path)] + ) - # Assert command succeeded assert result.exit_code == 0 - - # Verify the output mentions the custom path (either original or resolved form) assert ( str(custom_db_path) in result.stdout or str(resolved_custom_db_path) in result.stdout ) - assert "Target database path:" in result.stdout + assert "DuckDB path:" in result.stdout - # Verify initialize_dataset was called with the resolved custom path - mock_initialize_dataset.assert_called_once_with( + mock_init.assert_called_once_with( dataset_name="mimic-iv-demo", db_target_path=resolved_custom_db_path ) - - # Verify sqlite connection was attempted with the resolved custom path - mock_sqlite_connect.assert_called_with(resolved_custom_db_path) - - -def test_config_validation_sqlite_with_project_id(): - """Test that sqlite backend rejects project-id parameter.""" - result = runner.invoke( - app, ["config", "claude", "--backend", "sqlite", "--project-id", "test"] - ) - assert result.exit_code == 1 - # Check output - error messages from typer usually go to stdout - assert "project-id can only be used with --backend bigquery" in result.output + mock_rowcount.assert_called() def test_config_validation_bigquery_with_db_path(): @@ -99,15 +78,13 @@ def test_config_validation_bigquery_with_db_path(): app, ["config", "claude", "--backend", "bigquery", "--db-path", "/test/path"] ) assert result.exit_code == 1 - # Check output - error messages from typer usually go to stdout - assert "db-path can only be used with --backend sqlite" in result.output + assert "db-path can only be used with --backend duckdb" in result.output def test_config_validation_bigquery_requires_project_id(): """Test that bigquery backend requires project-id parameter.""" result = runner.invoke(app, ["config", "claude", "--backend", "bigquery"]) assert result.exit_code == 1 - # Check output - error messages from typer usually go to stdout assert "project-id is required when using --backend bigquery" in result.output @@ -120,10 +97,9 @@ def test_config_claude_success(mock_subprocess): assert result.exit_code == 0 assert "Claude Desktop configuration completed" in result.stdout - # Verify subprocess was called with correct script mock_subprocess.assert_called_once() call_args = mock_subprocess.call_args[0][0] - assert "setup_claude_desktop.py" in call_args[1] # Script path is second argument + assert "setup_claude_desktop.py" in call_args[1] @patch("subprocess.run") @@ -135,19 +111,7 @@ def test_config_universal_quick_mode(mock_subprocess): assert result.exit_code == 0 assert "Generating M3 MCP configuration" in result.stdout - # Verify subprocess was called with dynamic config script mock_subprocess.assert_called_once() call_args = mock_subprocess.call_args[0][0] - assert "dynamic_mcp_config.py" in call_args[1] # Script path is second argument + assert "dynamic_mcp_config.py" in call_args[1] assert "--quick" in call_args - - -@patch("subprocess.run") -def test_config_script_failure(mock_subprocess): - """Test error handling when config script fails.""" - mock_subprocess.side_effect = subprocess.CalledProcessError(1, "cmd") - - result = runner.invoke(app, ["config", "claude"]) - assert result.exit_code == 1 - # Just verify that the command failed with the right exit code - # The specific error message may vary diff --git a/tests/test_config.py b/tests/test_config.py index 6b159f3..30fde3b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,7 @@ from m3.config import ( get_dataset_config, - get_dataset_raw_files_path, + get_dataset_parquet_root, get_default_database_path, ) @@ -10,7 +10,7 @@ def test_get_dataset_config_known(): cfg = get_dataset_config("mimic-iv-demo") assert isinstance(cfg, dict) - assert cfg.get("default_db_filename") == "mimic_iv_demo.db" + assert cfg.get("default_duckdb_filename") == "mimic_iv_demo.duckdb" def test_get_dataset_config_unknown(): @@ -22,9 +22,9 @@ def test_default_paths(tmp_path, monkeypatch): import m3.config as cfg_mod monkeypatch.setattr(cfg_mod, "DEFAULT_DATABASES_DIR", tmp_path / "dbs") - monkeypatch.setattr(cfg_mod, "DEFAULT_RAW_FILES_DIR", tmp_path / "raw") - db_path = get_default_database_path("mimic-iv-demo") - raw_path = get_dataset_raw_files_path("mimic-iv-demo") + monkeypatch.setattr(cfg_mod, "DEFAULT_PARQUET_DIR", tmp_path / "parquet") + db_path = get_default_database_path("mimic-iv-demo", engine="duckdb") + raw_path = get_dataset_parquet_root("mimic-iv-demo") # They should be Path objects and exist assert isinstance(db_path, Path) assert db_path.parent.exists() @@ -35,6 +35,6 @@ def test_default_paths(tmp_path, monkeypatch): def test_raw_path_includes_dataset_name(tmp_path, monkeypatch): import m3.config as cfg_mod - monkeypatch.setattr(cfg_mod, "DEFAULT_RAW_FILES_DIR", tmp_path / "raw") - raw_path = get_dataset_raw_files_path("mimic-iv-demo") + monkeypatch.setattr(cfg_mod, "DEFAULT_PARQUET_DIR", tmp_path / "parquet") + raw_path = get_dataset_parquet_root("mimic-iv-demo") assert "mimic-iv-demo" in str(raw_path) diff --git a/tests/test_config_scripts.py b/tests/test_config_scripts.py index 2430c77..57b8e89 100644 --- a/tests/test_config_scripts.py +++ b/tests/test_config_scripts.py @@ -14,8 +14,8 @@ class TestMCPConfigGenerator: """Test the MCPConfigGenerator class.""" - def test_generate_config_sqlite_default(self): - """Test generating SQLite config with defaults.""" + def test_generate_config_duckdb_default(self): + """Test generating DuckDB config with defaults.""" generator = MCPConfigGenerator() with ( @@ -24,7 +24,7 @@ def test_generate_config_sqlite_default(self): ): config = generator.generate_config() - assert config["mcpServers"]["m3"]["env"]["M3_BACKEND"] == "sqlite" + assert config["mcpServers"]["m3"]["env"]["M3_BACKEND"] == "duckdb" assert "M3_PROJECT_ID" not in config["mcpServers"]["m3"]["env"] assert config["mcpServers"]["m3"]["args"] == ["-m", "m3.mcp_server"] @@ -47,8 +47,8 @@ def test_generate_config_bigquery_with_project(self): == "test-project" ) - def test_generate_config_sqlite_with_db_path(self): - """Test generating SQLite config with custom database path.""" + def test_generate_config_duckdb_with_db_path(self): + """Test generating DuckDB config with custom database path.""" generator = MCPConfigGenerator() with ( @@ -56,13 +56,13 @@ def test_generate_config_sqlite_with_db_path(self): patch.object(generator, "_validate_directory", return_value=True), ): config = generator.generate_config( - backend="sqlite", db_path="/custom/path/database.db" + backend="duckdb", db_path="/custom/path/database.duckdb" ) - assert config["mcpServers"]["m3"]["env"]["M3_BACKEND"] == "sqlite" + assert config["mcpServers"]["m3"]["env"]["M3_BACKEND"] == "duckdb" assert ( config["mcpServers"]["m3"]["env"]["M3_DB_PATH"] - == "/custom/path/database.db" + == "/custom/path/database.duckdb" ) def test_generate_config_custom_server_name(self): @@ -93,7 +93,7 @@ def test_generate_config_additional_env_vars(self): env = config["mcpServers"]["m3"]["env"] assert env["DEBUG"] == "true" assert env["LOG_LEVEL"] == "info" - assert env["M3_BACKEND"] == "sqlite" # Default should still be there + assert env["M3_BACKEND"] == "duckdb" # Default should still be there def test_validation_invalid_python_path(self): """Test that invalid Python path raises error.""" diff --git a/tests/test_data_io.py b/tests/test_data_io.py index cc7e44f..8e753c1 100644 --- a/tests/test_data_io.py +++ b/tests/test_data_io.py @@ -1,50 +1,21 @@ -import requests +import duckdb -from m3.data_io import COMMON_USER_AGENT, _scrape_urls_from_html_page +from m3.data_io import compute_parquet_dir_size, verify_table_rowcount -class DummyResponse: - def __init__(self, content, status_code=200, headers=None): - self.content = content.encode() - self.status_code = status_code - self.headers = headers or {} +def test_compute_parquet_dir_size_empty(tmp_path): + size = compute_parquet_dir_size(tmp_path) + assert size == 0 - def raise_for_status(self): - if not (200 <= self.status_code < 300): - raise requests.exceptions.HTTPError(response=self) - @property - def reason(self): - return "Error" +def test_verify_table_rowcount_with_temp_duckdb(tmp_path): + db_path = tmp_path / "test.duckdb" + con = duckdb.connect(str(db_path)) + try: + con.execute("CREATE VIEW temp_numbers AS SELECT 1 AS x UNION ALL SELECT 2 AS x") + con.commit() + finally: + con.close() - def iter_content(self, chunk_size=1): - yield from self.content - - -def test_scrape_urls(monkeypatch): - html = ( - "" - 'ok' - 'no' - "" - ) - dummy = DummyResponse(html) - session = requests.Session() - monkeypatch.setattr(session, "get", lambda url, timeout=None: dummy) - urls = _scrape_urls_from_html_page("http://example.com/", session) - assert urls == ["http://example.com/file1.csv.gz"] - - -def test_scrape_no_matching_suffix(monkeypatch): - html = 'ok' - dummy = DummyResponse(html) - session = requests.Session() - monkeypatch.setattr(session, "get", lambda url, timeout=None: dummy) - urls = _scrape_urls_from_html_page("http://example.com/", session) - assert urls == [] - - -def test_common_user_agent_header(): - # Ensure the constant is set and looks like a UA string - assert isinstance(COMMON_USER_AGENT, str) - assert "Mozilla/" in COMMON_USER_AGENT + count = verify_table_rowcount(db_path, "temp_numbers") + assert count == 2 diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 334ad5f..d2c0b59 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -3,7 +3,6 @@ """ import os -import sqlite3 from pathlib import Path from unittest.mock import Mock, patch @@ -13,7 +12,7 @@ # Mock the database path check during import to handle CI environments with patch("pathlib.Path.exists", return_value=True): with patch( - "m3.mcp_server.get_default_database_path", return_value=Path("/fake/test.db") + "m3.mcp_server.get_default_database_path", return_value=Path("/fake/test.duckdb") ): from m3.mcp_server import _init_backend, mcp @@ -36,31 +35,31 @@ def test_server_instance_exists(self): assert mcp is not None assert mcp.name == "m3" - def test_backend_init_sqlite_default(self): - """Test SQLite backend initialization with defaults.""" - with patch.dict(os.environ, {"M3_BACKEND": "sqlite"}, clear=True): + def test_backend_init_duckdb_default(self): + """Test DuckDB backend initialization with defaults.""" + with patch.dict(os.environ, {"M3_BACKEND": "duckdb"}, clear=True): with patch("m3.mcp_server.get_default_database_path") as mock_path: - mock_path.return_value = Path("/fake/path.db") + mock_path.return_value = Path("/fake/path.duckdb") with patch("pathlib.Path.exists", return_value=True): _init_backend() # If no exception raised, initialization succeeded - def test_backend_init_sqlite_custom_path(self): - """Test SQLite backend initialization with custom path.""" + def test_backend_init_duckdb_custom_path(self): + """Test DuckDB backend initialization with custom path.""" with patch.dict( os.environ, - {"M3_BACKEND": "sqlite", "M3_DB_PATH": "/custom/path.db"}, + {"M3_BACKEND": "duckdb", "M3_DB_PATH": "/custom/path.duckdb"}, clear=True, ): with patch("pathlib.Path.exists", return_value=True): _init_backend() # If no exception raised, initialization succeeded - def test_backend_init_sqlite_missing_db(self): - """Test SQLite backend initialization with missing database.""" - with patch.dict(os.environ, {"M3_BACKEND": "sqlite"}, clear=True): + def test_backend_init_duckdb_missing_db(self): + """Test DuckDB backend initialization with missing database.""" + with patch.dict(os.environ, {"M3_BACKEND": "duckdb"}, clear=True): with patch("m3.mcp_server.get_default_database_path") as mock_path: - mock_path.return_value = Path("/fake/path.db") + mock_path.return_value = Path("/fake/path.duckdb") with patch("pathlib.Path.exists", return_value=False): with pytest.raises(FileNotFoundError): _init_backend() @@ -93,60 +92,62 @@ class TestMCPTools: @pytest.fixture def test_db(self, tmp_path): - """Create a test SQLite database.""" - db_path = tmp_path / "test.db" - - # Create test database with MIMIC-IV-like structure - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - # Create icu_icustays table - cursor.execute(""" - CREATE TABLE icu_icustays ( - subject_id INTEGER, - hadm_id INTEGER, - stay_id INTEGER, - intime TEXT, - outtime TEXT + """Create a test DuckDB database.""" + import duckdb + + db_path = tmp_path / "test.duckdb" + con = duckdb.connect(str(db_path)) + try: + con.execute( + """ + CREATE TABLE icu_icustays ( + subject_id INTEGER, + hadm_id INTEGER, + stay_id INTEGER, + intime TIMESTAMP, + outtime TIMESTAMP + ) + """ ) - """) - cursor.execute(""" - INSERT INTO icu_icustays (subject_id, hadm_id, stay_id, intime, outtime) - VALUES - (10000032, 20000001, 30000001, '2180-07-23 15:00:00', '2180-07-24 12:00:00'), - (10000033, 20000002, 30000002, '2180-08-15 10:30:00', '2180-08-16 14:15:00') - """) - - # Create hosp_labevents table - cursor.execute(""" - CREATE TABLE hosp_labevents ( - subject_id INTEGER, - hadm_id INTEGER, - itemid INTEGER, - charttime TEXT, - value TEXT + con.execute( + """ + INSERT INTO icu_icustays VALUES + (10000032, 20000001, 30000001, '2180-07-23 15:00:00', '2180-07-24 12:00:00'), + (10000033, 20000002, 30000002, '2180-08-15 10:30:00', '2180-08-16 14:15:00') + """ ) - """) - cursor.execute(""" - INSERT INTO hosp_labevents (subject_id, hadm_id, itemid, charttime, value) - VALUES - (10000032, 20000001, 50912, '2180-07-23 16:00:00', '120'), - (10000033, 20000002, 50912, '2180-08-15 11:00:00', '95') - """) - - conn.commit() - conn.close() + con.execute( + """ + CREATE TABLE hosp_labevents ( + subject_id INTEGER, + hadm_id INTEGER, + itemid INTEGER, + charttime TIMESTAMP, + value TEXT + ) + """ + ) + con.execute( + """ + INSERT INTO hosp_labevents VALUES + (10000032, 20000001, 50912, '2180-07-23 16:00:00', '120'), + (10000033, 20000002, 50912, '2180-08-15 11:00:00', '95') + """ + ) + con.commit() + finally: + con.close() return str(db_path) @pytest.mark.asyncio async def test_tools_via_client(self, test_db): """Test MCP tools through the FastMCP client.""" - # Set up environment for SQLite backend with OAuth2 disabled + # Set up environment for DuckDB backend with OAuth2 disabled with patch.dict( os.environ, { - "M3_BACKEND": "sqlite", + "M3_BACKEND": "duckdb", "M3_DB_PATH": test_db, "M3_OAUTH2_ENABLED": "false", }, @@ -191,7 +192,7 @@ async def test_security_checks(self, test_db): with patch.dict( os.environ, { - "M3_BACKEND": "sqlite", + "M3_BACKEND": "duckdb", "M3_DB_PATH": test_db, "M3_OAUTH2_ENABLED": "false", }, @@ -226,7 +227,7 @@ async def test_invalid_sql(self, test_db): with patch.dict( os.environ, { - "M3_BACKEND": "sqlite", + "M3_BACKEND": "duckdb", "M3_DB_PATH": test_db, "M3_OAUTH2_ENABLED": "false", }, @@ -247,7 +248,7 @@ async def test_empty_results(self, test_db): with patch.dict( os.environ, { - "M3_BACKEND": "sqlite", + "M3_BACKEND": "duckdb", "M3_DB_PATH": test_db, "M3_OAUTH2_ENABLED": "false", }, @@ -268,11 +269,11 @@ async def test_empty_results(self, test_db): @pytest.mark.asyncio async def test_oauth2_authentication_required(self, test_db): """Test that OAuth2 authentication is required when enabled.""" - # Set up environment for SQLite backend with OAuth2 enabled + # Set up environment for DuckDB backend with OAuth2 enabled with patch.dict( os.environ, { - "M3_BACKEND": "sqlite", + "M3_BACKEND": "duckdb", "M3_DB_PATH": test_db, "M3_OAUTH2_ENABLED": "true", "M3_OAUTH2_ISSUER_URL": "https://auth.example.com", diff --git a/uv.lock b/uv.lock index f2451ff..efb8015 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,148 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/34/939730e66b716b76046dedfe0842995842fa906ccc4964bba414ff69e429/aiohttp-3.13.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2372b15a5f62ed37789a6b383ff7344fc5b9f243999b0cd9b629d8bc5f5b4155", size = 736471, upload-time = "2025-10-28T20:55:27.924Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/dcbdf2df7f6ca72b0bb4c0b4509701f2d8942cf54e29ca197389c214c07f/aiohttp-3.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e7f8659a48995edee7229522984bd1009c1213929c769c2daa80b40fe49a180c", size = 493985, upload-time = "2025-10-28T20:55:29.456Z" }, + { url = "https://files.pythonhosted.org/packages/9d/87/71c8867e0a1d0882dcbc94af767784c3cb381c1c4db0943ab4aae4fed65e/aiohttp-3.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:939ced4a7add92296b0ad38892ce62b98c619288a081170695c6babe4f50e636", size = 489274, upload-time = "2025-10-28T20:55:31.134Z" }, + { url = "https://files.pythonhosted.org/packages/38/0f/46c24e8dae237295eaadd113edd56dee96ef6462adf19b88592d44891dc5/aiohttp-3.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6315fb6977f1d0dd41a107c527fee2ed5ab0550b7d885bc15fee20ccb17891da", size = 1668171, upload-time = "2025-10-28T20:55:36.065Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/4cdfb4440d0e28483681a48f69841fa5e39366347d66ef808cbdadddb20e/aiohttp-3.13.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6e7352512f763f760baaed2637055c49134fd1d35b37c2dedfac35bfe5cf8725", size = 1636036, upload-time = "2025-10-28T20:55:37.576Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/8708cf678628216fb678ab327a4e1711c576d6673998f4f43e86e9ae90dd/aiohttp-3.13.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e09a0a06348a2dd73e7213353c90d709502d9786219f69b731f6caa0efeb46f5", size = 1727975, upload-time = "2025-10-28T20:55:39.457Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2e/3ebfe12fdcb9b5f66e8a0a42dffcd7636844c8a018f261efb2419f68220b/aiohttp-3.13.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a09a6d073fb5789456545bdee2474d14395792faa0527887f2f4ec1a486a59d3", size = 1815823, upload-time = "2025-10-28T20:55:40.958Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ca2ef819488cbb41844c6cf92ca6dd15b9441e6207c58e5ae0e0fc8d70ad/aiohttp-3.13.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b59d13c443f8e049d9e94099c7e412e34610f1f49be0f230ec656a10692a5802", size = 1669374, upload-time = "2025-10-28T20:55:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/1fe2e1179a0d91ce09c99069684aab619bf2ccde9b20bd6ca44f8837203e/aiohttp-3.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:20db2d67985d71ca033443a1ba2001c4b5693fe09b0e29f6d9358a99d4d62a8a", size = 1555315, upload-time = "2025-10-28T20:55:44.264Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2b/f3781899b81c45d7cbc7140cddb8a3481c195e7cbff8e36374759d2ab5a5/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:960c2fc686ba27b535f9fd2b52d87ecd7e4fd1cf877f6a5cba8afb5b4a8bd204", size = 1639140, upload-time = "2025-10-28T20:55:46.626Z" }, + { url = "https://files.pythonhosted.org/packages/72/27/c37e85cd3ece6f6c772e549bd5a253d0c122557b25855fb274224811e4f2/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6c00dbcf5f0d88796151e264a8eab23de2997c9303dd7c0bf622e23b24d3ce22", size = 1645496, upload-time = "2025-10-28T20:55:48.933Z" }, + { url = "https://files.pythonhosted.org/packages/66/20/3af1ab663151bd3780b123e907761cdb86ec2c4e44b2d9b195ebc91fbe37/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fed38a5edb7945f4d1bcabe2fcd05db4f6ec7e0e82560088b754f7e08d93772d", size = 1697625, upload-time = "2025-10-28T20:55:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/95/eb/ae5cab15efa365e13d56b31b0d085a62600298bf398a7986f8388f73b598/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b395bbca716c38bef3c764f187860e88c724b342c26275bc03e906142fc5964f", size = 1542025, upload-time = "2025-10-28T20:55:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2d/1683e8d67ec72d911397fe4e575688d2a9b8f6a6e03c8fdc9f3fd3d4c03f/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:204ffff2426c25dfda401ba08da85f9c59525cdc42bda26660463dd1cbcfec6f", size = 1714918, upload-time = "2025-10-28T20:55:53.515Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ffe8e0e1c57c5e542d47ffa1fcf95ef2b3ea573bf7c4d2ee877252431efc/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:05c4dd3c48fb5f15db31f57eb35374cb0c09afdde532e7fb70a75aede0ed30f6", size = 1656113, upload-time = "2025-10-28T20:55:55.438Z" }, + { url = "https://files.pythonhosted.org/packages/0d/42/d511aff5c3a2b06c09d7d214f508a4ad8ac7799817f7c3d23e7336b5e896/aiohttp-3.13.2-cp310-cp310-win32.whl", hash = "sha256:e574a7d61cf10351d734bcddabbe15ede0eaa8a02070d85446875dc11189a251", size = 432290, upload-time = "2025-10-28T20:55:56.96Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ea/1c2eb7098b5bad4532994f2b7a8228d27674035c9b3234fe02c37469ef14/aiohttp-3.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:364f55663085d658b8462a1c3f17b2b84a5c2e1ba858e1b79bff7b2e24ad1514", size = 455075, upload-time = "2025-10-28T20:55:58.373Z" }, + { url = "https://files.pythonhosted.org/packages/35/74/b321e7d7ca762638cdf8cdeceb39755d9c745aff7a64c8789be96ddf6e96/aiohttp-3.13.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4647d02df098f6434bafd7f32ad14942f05a9caa06c7016fdcc816f343997dd0", size = 743409, upload-time = "2025-10-28T20:56:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/99/3d/91524b905ec473beaf35158d17f82ef5a38033e5809fe8742e3657cdbb97/aiohttp-3.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e3403f24bcb9c3b29113611c3c16a2a447c3953ecf86b79775e7be06f7ae7ccb", size = 497006, upload-time = "2025-10-28T20:56:01.85Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d3/7f68bc02a67716fe80f063e19adbd80a642e30682ce74071269e17d2dba1/aiohttp-3.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:43dff14e35aba17e3d6d5ba628858fb8cb51e30f44724a2d2f0c75be492c55e9", size = 493195, upload-time = "2025-10-28T20:56:03.314Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/913f774a4708775433b7375c4f867d58ba58ead833af96c8af3621a0d243/aiohttp-3.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2a9ea08e8c58bb17655630198833109227dea914cd20be660f52215f6de5613", size = 1747759, upload-time = "2025-10-28T20:56:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/04efe156f4326f31c7c4a97144f82132c3bb21859b7bb84748d452ccc17c/aiohttp-3.13.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53b07472f235eb80e826ad038c9d106c2f653584753f3ddab907c83f49eedead", size = 1704456, upload-time = "2025-10-28T20:56:06.986Z" }, + { url = "https://files.pythonhosted.org/packages/8e/02/4e16154d8e0a9cf4ae76f692941fd52543bbb148f02f098ca73cab9b1c1b/aiohttp-3.13.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e736c93e9c274fce6419af4aac199984d866e55f8a4cec9114671d0ea9688780", size = 1807572, upload-time = "2025-10-28T20:56:08.558Z" }, + { url = "https://files.pythonhosted.org/packages/34/58/b0583defb38689e7f06798f0285b1ffb3a6fb371f38363ce5fd772112724/aiohttp-3.13.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ff5e771f5dcbc81c64898c597a434f7682f2259e0cd666932a913d53d1341d1a", size = 1895954, upload-time = "2025-10-28T20:56:10.545Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f3/083907ee3437425b4e376aa58b2c915eb1a33703ec0dc30040f7ae3368c6/aiohttp-3.13.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3b6fb0c207cc661fa0bf8c66d8d9b657331ccc814f4719468af61034b478592", size = 1747092, upload-time = "2025-10-28T20:56:12.118Z" }, + { url = "https://files.pythonhosted.org/packages/ac/61/98a47319b4e425cc134e05e5f3fc512bf9a04bf65aafd9fdcda5d57ec693/aiohttp-3.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:97a0895a8e840ab3520e2288db7cace3a1981300d48babeb50e7425609e2e0ab", size = 1606815, upload-time = "2025-10-28T20:56:14.191Z" }, + { url = "https://files.pythonhosted.org/packages/97/4b/e78b854d82f66bb974189135d31fce265dee0f5344f64dd0d345158a5973/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9e8f8afb552297aca127c90cb840e9a1d4bfd6a10d7d8f2d9176e1acc69bad30", size = 1723789, upload-time = "2025-10-28T20:56:16.101Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fc/9d2ccc794fc9b9acd1379d625c3a8c64a45508b5091c546dea273a41929e/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ed2f9c7216e53c3df02264f25d824b079cc5914f9e2deba94155190ef648ee40", size = 1718104, upload-time = "2025-10-28T20:56:17.655Z" }, + { url = "https://files.pythonhosted.org/packages/66/65/34564b8765ea5c7d79d23c9113135d1dd3609173da13084830f1507d56cf/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:99c5280a329d5fa18ef30fd10c793a190d996567667908bef8a7f81f8202b948", size = 1785584, upload-time = "2025-10-28T20:56:19.238Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/f6a7a426e02fc82781afd62016417b3948e2207426d90a0e478790d1c8a4/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ca6ffef405fc9c09a746cb5d019c1672cd7f402542e379afc66b370833170cf", size = 1595126, upload-time = "2025-10-28T20:56:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c7/8e22d5d28f94f67d2af496f14a83b3c155d915d1fe53d94b66d425ec5b42/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:47f438b1a28e926c37632bff3c44df7d27c9b57aaf4e34b1def3c07111fdb782", size = 1800665, upload-time = "2025-10-28T20:56:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/91133c8b68b1da9fc16555706aa7276fdf781ae2bb0876c838dd86b8116e/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9acda8604a57bb60544e4646a4615c1866ee6c04a8edef9b8ee6fd1d8fa2ddc8", size = 1739532, upload-time = "2025-10-28T20:56:25.924Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/3747644d26a998774b21a616016620293ddefa4d63af6286f389aedac844/aiohttp-3.13.2-cp311-cp311-win32.whl", hash = "sha256:868e195e39b24aaa930b063c08bb0c17924899c16c672a28a65afded9c46c6ec", size = 431876, upload-time = "2025-10-28T20:56:27.524Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/688462108c1a00eb9f05765331c107f95ae86f6b197b865d29e930b7e462/aiohttp-3.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:7fd19df530c292542636c2a9a85854fab93474396a52f1695e799186bbd7f24c", size = 456205, upload-time = "2025-10-28T20:56:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/01f00e9856d0a73260e86dd8ed0c2234a466c5c1712ce1c281548df39777/aiohttp-3.13.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b1e56bab2e12b2b9ed300218c351ee2a3d8c8fdab5b1ec6193e11a817767e47b", size = 737623, upload-time = "2025-10-28T20:56:30.797Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1b/4be39c445e2b2bd0aab4ba736deb649fabf14f6757f405f0c9685019b9e9/aiohttp-3.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:364e25edaabd3d37b1db1f0cbcee8c73c9a3727bfa262b83e5e4cf3489a2a9dc", size = 492664, upload-time = "2025-10-28T20:56:32.708Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/d35dcfea8050e131cdd731dff36434390479b4045a8d0b9d7111b0a968f1/aiohttp-3.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c94825f744694c4b8db20b71dba9a257cd2ba8e010a803042123f3a25d50d7", size = 491808, upload-time = "2025-10-28T20:56:34.57Z" }, + { url = "https://files.pythonhosted.org/packages/00/29/8e4609b93e10a853b65f8291e64985de66d4f5848c5637cddc70e98f01f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba2715d842ffa787be87cbfce150d5e88c87a98e0b62e0f5aa489169a393dbbb", size = 1738863, upload-time = "2025-10-28T20:56:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fa/4ebdf4adcc0def75ced1a0d2d227577cd7b1b85beb7edad85fcc87693c75/aiohttp-3.13.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:585542825c4bc662221fb257889e011a5aa00f1ae4d75d1d246a5225289183e3", size = 1700586, upload-time = "2025-10-28T20:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/da/04/73f5f02ff348a3558763ff6abe99c223381b0bace05cd4530a0258e52597/aiohttp-3.13.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39d02cb6025fe1aabca329c5632f48c9532a3dabccd859e7e2f110668972331f", size = 1768625, upload-time = "2025-10-28T20:56:39.75Z" }, + { url = "https://files.pythonhosted.org/packages/f8/49/a825b79ffec124317265ca7d2344a86bcffeb960743487cb11988ffb3494/aiohttp-3.13.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e67446b19e014d37342f7195f592a2a948141d15a312fe0e700c2fd2f03124f6", size = 1867281, upload-time = "2025-10-28T20:56:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/b9/48/adf56e05f81eac31edcfae45c90928f4ad50ef2e3ea72cb8376162a368f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4356474ad6333e41ccefd39eae869ba15a6c5299c9c01dfdcfdd5c107be4363e", size = 1752431, upload-time = "2025-10-28T20:56:43.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/ab/593855356eead019a74e862f21523db09c27f12fd24af72dbc3555b9bfd9/aiohttp-3.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeacf451c99b4525f700f078becff32c32ec327b10dcf31306a8a52d78166de7", size = 1562846, upload-time = "2025-10-28T20:56:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/39/0f/9f3d32271aa8dc35036e9668e31870a9d3b9542dd6b3e2c8a30931cb27ae/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8a9b889aeabd7a4e9af0b7f4ab5ad94d42e7ff679aaec6d0db21e3b639ad58d", size = 1699606, upload-time = "2025-10-28T20:56:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/52d2658c5699b6ef7692a3f7128b2d2d4d9775f2a68093f74bca06cf01e1/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fa89cb11bc71a63b69568d5b8a25c3ca25b6d54c15f907ca1c130d72f320b76b", size = 1720663, upload-time = "2025-10-28T20:56:48.528Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d4/8f8f3ff1fb7fb9e3f04fcad4e89d8a1cd8fc7d05de67e3de5b15b33008ff/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8aa7c807df234f693fed0ecd507192fc97692e61fee5702cdc11155d2e5cadc8", size = 1737939, upload-time = "2025-10-28T20:56:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/03/d3/ddd348f8a27a634daae39a1b8e291ff19c77867af438af844bf8b7e3231b/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9eb3e33fdbe43f88c3c75fa608c25e7c47bbd80f48d012763cb67c47f39a7e16", size = 1555132, upload-time = "2025-10-28T20:56:52.568Z" }, + { url = "https://files.pythonhosted.org/packages/39/b8/46790692dc46218406f94374903ba47552f2f9f90dad554eed61bfb7b64c/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9434bc0d80076138ea986833156c5a48c9c7a8abb0c96039ddbb4afc93184169", size = 1764802, upload-time = "2025-10-28T20:56:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e4/19ce547b58ab2a385e5f0b8aa3db38674785085abcf79b6e0edd1632b12f/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff15c147b2ad66da1f2cbb0622313f2242d8e6e8f9b79b5206c84523a4473248", size = 1719512, upload-time = "2025-10-28T20:56:56.428Z" }, + { url = "https://files.pythonhosted.org/packages/70/30/6355a737fed29dcb6dfdd48682d5790cb5eab050f7b4e01f49b121d3acad/aiohttp-3.13.2-cp312-cp312-win32.whl", hash = "sha256:27e569eb9d9e95dbd55c0fc3ec3a9335defbf1d8bc1d20171a49f3c4c607b93e", size = 426690, upload-time = "2025-10-28T20:56:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/b10ac09069973d112de6ef980c1f6bb31cb7dcd0bc363acbdad58f927873/aiohttp-3.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:8709a0f05d59a71f33fd05c17fc11fcb8c30140506e13c2f5e8ee1b8964e1b45", size = 453465, upload-time = "2025-10-28T20:57:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" }, + { url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" }, + { url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" }, + { url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" }, + { url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234, upload-time = "2025-10-28T20:57:36.415Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733, upload-time = "2025-10-28T20:57:38.205Z" }, + { url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303, upload-time = "2025-10-28T20:57:40.122Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965, upload-time = "2025-10-28T20:57:42.28Z" }, + { url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221, upload-time = "2025-10-28T20:57:44.869Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178, upload-time = "2025-10-28T20:57:47.216Z" }, + { url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001, upload-time = "2025-10-28T20:57:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325, upload-time = "2025-10-28T20:57:51.327Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978, upload-time = "2025-10-28T20:57:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042, upload-time = "2025-10-28T20:57:55.617Z" }, + { url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085, upload-time = "2025-10-28T20:57:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238, upload-time = "2025-10-28T20:57:59.525Z" }, + { url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395, upload-time = "2025-10-28T20:58:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965, upload-time = "2025-10-28T20:58:03.972Z" }, + { url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585, upload-time = "2025-10-28T20:58:06.189Z" }, + { url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621, upload-time = "2025-10-28T20:58:08.636Z" }, + { url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627, upload-time = "2025-10-28T20:58:11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360, upload-time = "2025-10-28T20:58:13.358Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616, upload-time = "2025-10-28T20:58:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131, upload-time = "2025-10-28T20:58:17.693Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168, upload-time = "2025-10-28T20:58:20.113Z" }, + { url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200, upload-time = "2025-10-28T20:58:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497, upload-time = "2025-10-28T20:58:24.672Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703, upload-time = "2025-10-28T20:58:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738, upload-time = "2025-10-28T20:58:29.787Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061, upload-time = "2025-10-28T20:58:32.529Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201, upload-time = "2025-10-28T20:58:34.618Z" }, + { url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868, upload-time = "2025-10-28T20:58:38.835Z" }, + { url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660, upload-time = "2025-10-28T20:58:41.507Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548, upload-time = "2025-10-28T20:58:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240, upload-time = "2025-10-28T20:58:45.787Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334, upload-time = "2025-10-28T20:58:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685, upload-time = "2025-10-28T20:58:50.642Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -41,6 +183,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + [[package]] name = "attrs" version = "25.3.0" @@ -62,6 +213,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/29/587c189bbab1ccc8c86a03a5d0e13873df916380ef1be461ebe6acebf48d/authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d", size = 239981, upload-time = "2025-05-23T00:21:43.075Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.13.4" @@ -150,6 +310,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, ] +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.2" @@ -295,6 +464,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/91/66065d933b4814295fd0ddc16a66ef193dff14bf8d15895723f38640a3ab/db_dtypes-1.4.3-py3-none-any.whl", hash = "sha256:a1c92b819af947fae1701d80a71f2a0eac08f825ca52cf0c68aeba80577ae966", size = 18110, upload-time = "2025-05-12T13:54:20.146Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "dnspython" version = "2.7.0" @@ -393,6 +571,136 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/f6/df91b178740108bcee4c6df732369dfa9250578a979caac4cbd1e5d8b42c/fastmcp-2.10.2-py3-none-any.whl", hash = "sha256:3e5929772d5d22bad03581c2c4db40e008926309180168d48f3a7eac678ae645", size = 185331, upload-time = "2025-07-05T17:30:58.21Z" }, ] +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "google-api-core" version = "2.25.1" @@ -678,6 +986,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, ] +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -687,6 +1004,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jsonschema" version = "4.24.0" @@ -737,6 +1063,16 @@ dependencies = [ { name = "typer" }, ] +[package.dev-dependencies] +dev = [ + { name = "aiohttp" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "appdirs", specifier = ">=1.4.0" }, @@ -758,6 +1094,16 @@ requires-dist = [ { name = "typer", specifier = ">=0.9.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "aiohttp", specifier = ">=3.8.0" }, + { name = "pre-commit", specifier = ">=3.0.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "pytest-mock", specifier = ">=3.10.0" }, + { name = "ruff", specifier = ">=0.4.0" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -800,6 +1146,153 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "multidict" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/63/7bdd4adc330abcca54c85728db2327130e49e52e8c3ce685cec44e0f2e9f/multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349", size = 77153, upload-time = "2025-10-06T14:48:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/b6c35ff175ed1a3142222b78455ee31be71a8396ed3ab5280fbe3ebe4e85/multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e", size = 44993, upload-time = "2025-10-06T14:48:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1f/064c77877c5fa6df6d346e68075c0f6998547afe952d6471b4c5f6a7345d/multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3", size = 44607, upload-time = "2025-10-06T14:48:29.581Z" }, + { url = "https://files.pythonhosted.org/packages/04/7a/bf6aa92065dd47f287690000b3d7d332edfccb2277634cadf6a810463c6a/multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046", size = 241847, upload-time = "2025-10-06T14:48:32.107Z" }, + { url = "https://files.pythonhosted.org/packages/94/39/297a8de920f76eda343e4ce05f3b489f0ab3f9504f2576dfb37b7c08ca08/multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32", size = 242616, upload-time = "2025-10-06T14:48:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/39/3a/d0eee2898cfd9d654aea6cb8c4addc2f9756e9a7e09391cfe55541f917f7/multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73", size = 222333, upload-time = "2025-10-06T14:48:35.9Z" }, + { url = "https://files.pythonhosted.org/packages/05/48/3b328851193c7a4240815b71eea165b49248867bbb6153a0aee227a0bb47/multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc", size = 253239, upload-time = "2025-10-06T14:48:37.302Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ca/0706a98c8d126a89245413225ca4a3fefc8435014de309cf8b30acb68841/multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62", size = 251618, upload-time = "2025-10-06T14:48:38.963Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/9c7992f245554d8b173f6f0a048ad24b3e645d883f096857ec2c0822b8bd/multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84", size = 241655, upload-time = "2025-10-06T14:48:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/31/79/26a85991ae67efd1c0b1fc2e0c275b8a6aceeb155a68861f63f87a798f16/multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0", size = 239245, upload-time = "2025-10-06T14:48:41.848Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/75fa96394478930b79d0302eaf9a6c69f34005a1a5251ac8b9c336486ec9/multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e", size = 233523, upload-time = "2025-10-06T14:48:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5e/085544cb9f9c4ad2b5d97467c15f856df8d9bac410cffd5c43991a5d878b/multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4", size = 243129, upload-time = "2025-10-06T14:48:45.225Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c3/e9d9e2f20c9474e7a8fcef28f863c5cbd29bb5adce6b70cebe8bdad0039d/multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648", size = 248999, upload-time = "2025-10-06T14:48:46.703Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3f/df171b6efa3239ae33b97b887e42671cd1d94d460614bfb2c30ffdab3b95/multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111", size = 243711, upload-time = "2025-10-06T14:48:48.146Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2f/9b5564888c4e14b9af64c54acf149263721a283aaf4aa0ae89b091d5d8c1/multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36", size = 237504, upload-time = "2025-10-06T14:48:49.447Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3a/0bd6ca0f7d96d790542d591c8c3354c1e1b6bfd2024d4d92dc3d87485ec7/multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85", size = 41422, upload-time = "2025-10-06T14:48:50.789Z" }, + { url = "https://files.pythonhosted.org/packages/00/35/f6a637ea2c75f0d3b7c7d41b1189189acff0d9deeb8b8f35536bb30f5e33/multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7", size = 46050, upload-time = "2025-10-06T14:48:51.938Z" }, + { url = "https://files.pythonhosted.org/packages/e7/b8/f7bf8329b39893d02d9d95cf610c75885d12fc0f402b1c894e1c8e01c916/multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0", size = 43153, upload-time = "2025-10-06T14:48:53.146Z" }, + { url = "https://files.pythonhosted.org/packages/34/9e/5c727587644d67b2ed479041e4b1c58e30afc011e3d45d25bbe35781217c/multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", size = 76604, upload-time = "2025-10-06T14:48:54.277Z" }, + { url = "https://files.pythonhosted.org/packages/17/e4/67b5c27bd17c085a5ea8f1ec05b8a3e5cba0ca734bfcad5560fb129e70ca/multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", size = 44715, upload-time = "2025-10-06T14:48:55.445Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e1/866a5d77be6ea435711bef2a4291eed11032679b6b28b56b4776ab06ba3e/multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", size = 44332, upload-time = "2025-10-06T14:48:56.706Z" }, + { url = "https://files.pythonhosted.org/packages/31/61/0c2d50241ada71ff61a79518db85ada85fdabfcf395d5968dae1cbda04e5/multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", size = 245212, upload-time = "2025-10-06T14:48:58.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e0/919666a4e4b57fff1b57f279be1c9316e6cdc5de8a8b525d76f6598fefc7/multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", size = 246671, upload-time = "2025-10-06T14:49:00.004Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cc/d027d9c5a520f3321b65adea289b965e7bcbd2c34402663f482648c716ce/multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", size = 225491, upload-time = "2025-10-06T14:49:01.393Z" }, + { url = "https://files.pythonhosted.org/packages/75/c4/bbd633980ce6155a28ff04e6a6492dd3335858394d7bb752d8b108708558/multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", size = 257322, upload-time = "2025-10-06T14:49:02.745Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6d/d622322d344f1f053eae47e033b0b3f965af01212de21b10bcf91be991fb/multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", size = 254694, upload-time = "2025-10-06T14:49:04.15Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/78f8761c2705d4c6d7516faed63c0ebdac569f6db1bef95e0d5218fdc146/multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", size = 246715, upload-time = "2025-10-06T14:49:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/950818e04f91b9c2b95aab3d923d9eabd01689d0dcd889563988e9ea0fd8/multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", size = 243189, upload-time = "2025-10-06T14:49:07.37Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/77c79e1934cad2ee74991840f8a0110966d9599b3af95964c0cd79bb905b/multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", size = 237845, upload-time = "2025-10-06T14:49:08.759Z" }, + { url = "https://files.pythonhosted.org/packages/63/1b/834ce32a0a97a3b70f86437f685f880136677ac00d8bce0027e9fd9c2db7/multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", size = 246374, upload-time = "2025-10-06T14:49:10.574Z" }, + { url = "https://files.pythonhosted.org/packages/23/ef/43d1c3ba205b5dec93dc97f3fba179dfa47910fc73aaaea4f7ceb41cec2a/multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", size = 253345, upload-time = "2025-10-06T14:49:12.331Z" }, + { url = "https://files.pythonhosted.org/packages/6b/03/eaf95bcc2d19ead522001f6a650ef32811aa9e3624ff0ad37c445c7a588c/multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", size = 246940, upload-time = "2025-10-06T14:49:13.821Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/ec8a5fd66ea6cd6f525b1fcbb23511b033c3e9bc42b81384834ffa484a62/multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", size = 242229, upload-time = "2025-10-06T14:49:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a2/59b405d59fd39ec86d1142630e9049243015a5f5291ba49cadf3c090c541/multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", size = 41308, upload-time = "2025-10-06T14:49:16.871Z" }, + { url = "https://files.pythonhosted.org/packages/32/0f/13228f26f8b882c34da36efa776c3b7348455ec383bab4a66390e42963ae/multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", size = 46037, upload-time = "2025-10-06T14:49:18.457Z" }, + { url = "https://files.pythonhosted.org/packages/84/1f/68588e31b000535a3207fd3c909ebeec4fb36b52c442107499c18a896a2a/multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", size = 43023, upload-time = "2025-10-06T14:49:19.648Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, + { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, + { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, + { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, + { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + [[package]] name = "numpy" version = "2.2.6" @@ -998,6 +1491,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/f9/07086f5b0f2a19872554abeea7658200824f5835c58a106fa8f2ae96a46c/pandas-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5db9637dbc24b631ff3707269ae4559bce4b7fd75c1c4d7e13f40edc42df4444", size = 13189044, upload-time = "2025-07-07T19:19:39.999Z" }, ] +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "polars" version = "1.31.0" @@ -1017,6 +1528,136 @@ pyarrow = [ { name = "pyarrow" }, ] +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, + { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, + { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + [[package]] name = "proto-plus" version = "1.26.1" @@ -1270,6 +1911,50 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1328,6 +2013,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "referencing" version = "0.36.2" @@ -1509,6 +2258,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] +[[package]] +name = "ruff" +version = "0.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" }, + { url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" }, + { url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" }, + { url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -1624,6 +2399,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/95/38ef0cd7fa11eaba6a99b3c4f5ac948d8bc6ff199aabd327a29cc000840c/starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527", size = 72747, upload-time = "2025-06-21T04:03:15.705Z" }, ] +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + [[package]] name = "typer" version = "0.16.0" @@ -1691,3 +2515,144 @@ sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8 wheels = [ { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, ] + +[[package]] +name = "virtualenv" +version = "20.35.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/d5/b0ccd381d55c8f45d46f77df6ae59fbc23d19e901e2d523395598e5f4c93/virtualenv-20.35.3.tar.gz", hash = "sha256:4f1a845d131133bdff10590489610c98c168ff99dc75d6c96853801f7f67af44", size = 6002907, upload-time = "2025-10-10T21:23:33.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl", hash = "sha256:63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a", size = 5981061, upload-time = "2025-10-10T21:23:30.433Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, + { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, + { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, + { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] From aa8a68dcfa683d018baae3b22f2e645660333239 Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 29 Oct 2025 15:49:40 -0400 Subject: [PATCH 05/13] Fix small errors, improve CLI UX, add tests for new functionality --- README.md | 1 + src/m3/cli.py | 44 +++++++++----- src/m3/config.py | 74 +++++++++++++----------- src/m3/data_io.py | 23 +++----- tests/test_cli.py | 115 ++++++++++++++++++++++++++++++++++++ tests/test_config.py | 6 +- tests/test_data_io.py | 122 ++++++++++++++++++++++++++++++++++++++- tests/test_mcp_server.py | 4 +- 8 files changed, 320 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index b7c21f3..fd846eb 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,7 @@ After installation, choose your data source: ```bash m3 convert mimic-iv-full ``` + - This may take up to 30 minutes, depending on your system (e.g. 10 minutes for MacBook Pro M3) - Default destination: `/Users/you/path/to/m3/m3_data/parquet/mimic-iv-full/` - Performance knobs (optional): ```bash diff --git a/src/m3/cli.py b/src/m3/cli.py index d67118a..0b7fb32 100644 --- a/src/m3/cli.py +++ b/src/m3/cli.py @@ -98,8 +98,8 @@ def dataset_init_cmd( """ Initialize a local dataset by creating DuckDB views over existing Parquet files. - - Parquet must already exist under /Users/hannesill/Developer/m3/m3_data/parquet// - - DuckDB file will be at /Users/hannesill/Developer/m3/m3_data/databases/.duckdb + - Parquet must already exist under /m3_data/parquet// + - DuckDB file will be at /m3_data/databases/.duckdb """ logger.info(f"CLI 'init' called for dataset: '{dataset_name}'") @@ -244,21 +244,28 @@ def use_cmd( def status_cmd(): """Show active dataset, local DB path, Parquet presence, quick counts and sizes.""" active = get_active_dataset() or "(unset)" - typer.echo(f"Active dataset: {active}") + typer.secho(f"Active dataset: {active}", fg=typer.colors.BRIGHT_GREEN if active != "(unset)" else typer.colors.YELLOW) availability = detect_available_local_datasets() for label in ("demo", "full"): info = availability[label] - typer.echo( - f"{label}: parquet_present={info['parquet_present']} db_present={info['db_present']}\n parquet_root={info['parquet_root']}\n db_path={info['db_path']}" - ) + typer.secho(f"\n=== {label.upper()} ===", fg=typer.colors.BRIGHT_BLUE) + + parquet_icon = "✅" if info["parquet_present"] else "❌" + db_icon = "✅" if info["db_present"] else "❌" + + typer.echo(f" parquet_present: {parquet_icon} db_present: {db_icon}") + typer.echo(f" parquet_root: {info['parquet_root']}") + typer.echo(f" db_path: {info['db_path']}") + if info["parquet_present"]: try: size_bytes = compute_parquet_dir_size(Path(info["parquet_root"])) - typer.echo(f" parquet_size_bytes={size_bytes}") + size_gb = float(size_bytes) / (1024 ** 3) + typer.echo(f" parquet_size_gb: {size_gb:.4f} GB") except Exception: - typer.echo(" parquet_size_bytes=(skipped)") + typer.echo(" parquet_size_gb: (skipped)") # Try a quick rowcount on the verification table if db present ds_name = "mimic-iv-demo" if label == "demo" else "mimic-iv-full" @@ -266,9 +273,9 @@ def status_cmd(): if info["db_present"] and cfg: try: count = verify_table_rowcount(Path(info["db_path"]), cfg["primary_verification_table"]) - typer.echo(f" {cfg['primary_verification_table']}_rowcount={count}") + typer.echo(f" {cfg['primary_verification_table']}_rowcount: {count:,}") except Exception: - typer.echo(" rowcount=(skipped)") + typer.echo(" rowcount: (skipped)") @app.command("config") @@ -392,14 +399,25 @@ def config_cmd( ) raise typer.Exit(code=1) - # Build command arguments + # Build command arguments with smart defaults inferred from runtime config cmd = [sys.executable, str(script_path)] + # Always pass backend if not duckdb; duckdb is the script default if backend != "duckdb": cmd.extend(["--backend", backend]) - if backend == "duckdb" and db_path: - cmd.extend(["--db-path", db_path]) + # For duckdb, infer db_path from active dataset if not provided + if backend == "duckdb": + effective_db_path = db_path + if not effective_db_path: + active = get_active_dataset() + # Default to demo if unset + dataset_key = "mimic-iv-full" if active == "full" else "mimic-iv-demo" + guessed = get_default_database_path(dataset_key, "duckdb") + if guessed is not None: + effective_db_path = str(guessed) + if effective_db_path: + cmd.extend(["--db-path", effective_db_path]) elif backend == "bigquery" and project_id: cmd.extend(["--project-id", project_id]) diff --git a/src/m3/config.py b/src/m3/config.py index 7c1860a..93489eb 100644 --- a/src/m3/config.py +++ b/src/m3/config.py @@ -36,9 +36,9 @@ def _get_project_root() -> Path: _PROJECT_ROOT = _get_project_root() _PROJECT_DATA_DIR = _PROJECT_ROOT / "m3_data" -DEFAULT_DATABASES_DIR = _PROJECT_DATA_DIR / "databases" -DEFAULT_PARQUET_DIR = _PROJECT_DATA_DIR / "parquet" -RUNTIME_CONFIG_PATH = _PROJECT_DATA_DIR / "config.json" +_DEFAULT_DATABASES_DIR = _PROJECT_DATA_DIR / "databases" +_DEFAULT_PARQUET_DIR = _PROJECT_DATA_DIR / "parquet" +_RUNTIME_CONFIG_PATH = _PROJECT_DATA_DIR / "config.json" # -------------------------------------------------- @@ -90,12 +90,12 @@ def get_default_database_path(dataset_name: str, engine: Literal["duckdb", "bigq logger.warning(f"Unknown dataset, cannot determine default DB path: {dataset_name}") return None - DEFAULT_DATABASES_DIR.mkdir(parents=True, exist_ok=True) + _DEFAULT_DATABASES_DIR.mkdir(parents=True, exist_ok=True) db_fname = cfg.get("default_duckdb_filename") if not db_fname: logger.warning(f"Missing default DuckDB filename for dataset: {dataset_name}") return None - return DEFAULT_DATABASES_DIR / db_fname + return _DEFAULT_DATABASES_DIR / db_fname def get_dataset_parquet_root(dataset_name: str) -> Path | None: """ @@ -106,7 +106,7 @@ def get_dataset_parquet_root(dataset_name: str) -> Path | None: if not cfg: logger.warning(f"Unknown dataset, cannot determine Parquet root: {dataset_name}") return None - path = DEFAULT_PARQUET_DIR / dataset_name.lower() + path = _DEFAULT_PARQUET_DIR / dataset_name.lower() path.mkdir(parents=True, exist_ok=True) return path @@ -115,40 +115,44 @@ def get_dataset_parquet_root(dataset_name: str) -> Path | None: # Runtime config (active dataset) # ----------------------------- def _ensure_data_dirs(): - DEFAULT_DATABASES_DIR.mkdir(parents=True, exist_ok=True) - DEFAULT_PARQUET_DIR.mkdir(parents=True, exist_ok=True) + _DEFAULT_DATABASES_DIR.mkdir(parents=True, exist_ok=True) + _DEFAULT_PARQUET_DIR.mkdir(parents=True, exist_ok=True) _PROJECT_DATA_DIR.mkdir(parents=True, exist_ok=True) def get_runtime_config_path() -> Path: - return RUNTIME_CONFIG_PATH + return _RUNTIME_CONFIG_PATH + + + +_DEFAULT_RUNTIME_CONFIG = { + "active_dataset": None, + "duckdb_paths": { + "demo": str(get_default_database_path("mimic-iv-demo") or ""), + "full": str(get_default_database_path("mimic-iv-full") or ""), + }, + "parquet_roots": { + "demo": str(get_dataset_parquet_root("mimic-iv-demo") or ""), + "full": str(get_dataset_parquet_root("mimic-iv-full") or ""), + }, +} def load_runtime_config() -> dict: """Load runtime configuration from /m3_data/config.json.""" _ensure_data_dirs() - if RUNTIME_CONFIG_PATH.exists(): + if _RUNTIME_CONFIG_PATH.exists(): try: - return json.loads(RUNTIME_CONFIG_PATH.read_text()) + return json.loads(_RUNTIME_CONFIG_PATH.read_text()) except Exception: logger.warning("Could not parse runtime config; using defaults") # defaults - return { - "active_dataset": None, - "duckdb_paths": { - "demo": str(get_default_database_path("mimic-iv-demo") or ""), - "full": str(get_default_database_path("mimic-iv-full") or ""), - }, - "parquet_roots": { - "demo": str(get_dataset_parquet_root("mimic-iv-demo") or ""), - "full": str(get_dataset_parquet_root("mimic-iv-full") or ""), - }, - } + return _DEFAULT_RUNTIME_CONFIG def save_runtime_config(cfg: dict) -> None: _ensure_data_dirs() - RUNTIME_CONFIG_PATH.write_text(json.dumps(cfg, indent=2)) + _RUNTIME_CONFIG_PATH.write_text(json.dumps(cfg, indent=2)) def _has_parquet_files(path: Path | None) -> bool: @@ -158,22 +162,22 @@ def _has_parquet_files(path: Path | None) -> bool: def detect_available_local_datasets() -> dict: """Return presence flags for demo/full based on Parquet roots and DuckDB files.""" cfg = load_runtime_config() - demo_parquet = Path(cfg["parquet_roots"]["demo"]) if cfg["parquet_roots"]["demo"] else get_dataset_parquet_root("mimic-iv-demo") - full_parquet = Path(cfg["parquet_roots"]["full"]) if cfg["parquet_roots"]["full"] else get_dataset_parquet_root("mimic-iv-full") - demo_db = Path(cfg["duckdb_paths"]["demo"]) if cfg["duckdb_paths"]["demo"] else get_default_database_path("mimic-iv-demo") - full_db = Path(cfg["duckdb_paths"]["full"]) if cfg["duckdb_paths"]["full"] else get_default_database_path("mimic-iv-full") + demo_parquet_path = Path(cfg["parquet_roots"]["demo"]) if cfg["parquet_roots"]["demo"] else get_dataset_parquet_root("mimic-iv-demo") + full_parquet_path = Path(cfg["parquet_roots"]["full"]) if cfg["parquet_roots"]["full"] else get_dataset_parquet_root("mimic-iv-full") + demo_db_path = Path(cfg["duckdb_paths"]["demo"]) if cfg["duckdb_paths"]["demo"] else get_default_database_path("mimic-iv-demo") + full_db_path = Path(cfg["duckdb_paths"]["full"]) if cfg["duckdb_paths"]["full"] else get_default_database_path("mimic-iv-full") return { "demo": { - "parquet_present": _has_parquet_files(demo_parquet), - "db_present": bool(demo_db and demo_db.exists()), - "parquet_root": str(demo_parquet) if demo_parquet else "", - "db_path": str(demo_db) if demo_db else "", + "parquet_present": _has_parquet_files(demo_parquet_path), + "db_present": bool(demo_db_path and demo_db_path.exists()), + "parquet_root": str(demo_parquet_path) if demo_parquet_path else "", + "db_path": str(demo_db_path) if demo_db_path else "", }, "full": { - "parquet_present": _has_parquet_files(full_parquet), - "db_present": bool(full_db and full_db.exists()), - "parquet_root": str(full_parquet) if full_parquet else "", - "db_path": str(full_db) if full_db else "", + "parquet_present": _has_parquet_files(full_parquet_path), + "db_present": bool(full_db_path and full_db_path.exists()), + "parquet_root": str(full_parquet_path) if full_parquet_path else "", + "db_path": str(full_db_path) if full_db_path else "", }, } diff --git a/src/m3/data_io.py b/src/m3/data_io.py index 0333cc6..995f02c 100644 --- a/src/m3/data_io.py +++ b/src/m3/data_io.py @@ -250,12 +250,16 @@ def _convert_one(csv_gz: Path) -> tuple[Path | None, float]: con.execute(f"SET memory_limit='{mem_limit}'") con.execute(f"PRAGMA threads={threads}") - # Streamed CSV -> Parquet conversion - # 'all_varchar=true' avoids expensive/wide type inference; - # if you prefer typed inference, drop it and keep sample_size=-1. + # Streamed CSV -> Parquet conversion with robust parsing sql = f""" COPY ( - SELECT * FROM read_csv_auto('{csv_gz.as_posix()}', sample_size=-1, all_varchar=true) + SELECT * FROM read_csv_auto( + '{csv_gz.as_posix()}', + sample_size=-1, + auto_detect=true, + nullstr=['', 'NULL', 'NA', 'N/A', '___'], + ignore_errors=false + ) ) TO '{out.as_posix()}' (FORMAT PARQUET, COMPRESSION ZSTD); """ @@ -430,17 +434,6 @@ def _create_duckdb_with_views(db_path: Path, parquet_root: Path) -> bool: con.close() -def build_duckdb_from_existing_raw(dataset_name: str, db_target_path: Path) -> bool: - """Deprecated. Use initialize_duckdb_from_parquet with Parquet already available.""" - parquet_root = get_dataset_parquet_root(dataset_name) - if not parquet_root or not parquet_root.exists(): - logger.error( - f"Parquet directory not found for dataset '{dataset_name}'. Expected at /m3_data/parquet/{dataset_name}/" - ) - return False - return _create_duckdb_with_views(db_target_path, parquet_root) - - ######################################################## # Verification and utilities ######################################################## diff --git a/tests/test_cli.py b/tests/test_cli.py index 02eb32e..410b4db 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import tempfile +import subprocess from pathlib import Path from unittest.mock import MagicMock, patch @@ -18,7 +19,9 @@ def inject_version(monkeypatch): def test_help_shows_app_name(): result = runner.invoke(app, ["--help"]) + # exit code 0 for successful help display assert result.exit_code == 0 + # help output contains the app name assert "M3 CLI" in result.stdout @@ -30,6 +33,7 @@ def test_version_option_exits_zero_and_shows_version(): def test_unknown_command_reports_error(): result = runner.invoke(app, ["not-a-cmd"]) + # unknown command should fail assert result.exit_code != 0 # Check both stdout and stderr since error messages might go to either depending on environment error_message = "No such command 'not-a-cmd'" @@ -66,9 +70,11 @@ def test_init_command_duckdb_custom_path(mock_rowcount, mock_init): ) assert "DuckDB path:" in result.stdout + # initializer should be called with the resolved path mock_init.assert_called_once_with( dataset_name="mimic-iv-demo", db_target_path=resolved_custom_db_path ) + # verification query should be attempted mock_rowcount.assert_called() @@ -77,6 +83,7 @@ def test_config_validation_bigquery_with_db_path(): result = runner.invoke( app, ["config", "claude", "--backend", "bigquery", "--db-path", "/test/path"] ) + # should fail when db-path is provided with bigquery assert result.exit_code == 1 assert "db-path can only be used with --backend duckdb" in result.output @@ -84,10 +91,22 @@ def test_config_validation_bigquery_with_db_path(): def test_config_validation_bigquery_requires_project_id(): """Test that bigquery backend requires project-id parameter.""" result = runner.invoke(app, ["config", "claude", "--backend", "bigquery"]) + # missing project-id should fail for bigquery backend assert result.exit_code == 1 assert "project-id is required when using --backend bigquery" in result.output +def test_config_validation_duckdb_with_project_id(): + """Test that duckdb backend rejects project-id parameter.""" + result = runner.invoke( + app, ["config", "claude", "--backend", "duckdb", "--project-id", "test"] + ) + # should fail when project-id is provided with duckdb + assert result.exit_code == 1 + # Check output - error messages from typer usually go to stdout + assert "project-id can only be used with --backend bigquery" in result.output + + @patch("subprocess.run") def test_config_claude_success(mock_subprocess): """Test successful Claude Desktop configuration.""" @@ -99,6 +118,7 @@ def test_config_claude_success(mock_subprocess): mock_subprocess.assert_called_once() call_args = mock_subprocess.call_args[0][0] + # correct script should be invoked assert "setup_claude_desktop.py" in call_args[1] @@ -115,3 +135,98 @@ def test_config_universal_quick_mode(mock_subprocess): call_args = mock_subprocess.call_args[0][0] assert "dynamic_mcp_config.py" in call_args[1] assert "--quick" in call_args + + +@patch("subprocess.run") +def test_config_script_failure(mock_subprocess): + """Test error handling when config script fails.""" + mock_subprocess.side_effect = subprocess.CalledProcessError(1, "cmd") + + result = runner.invoke(app, ["config", "claude"]) + # command should return failure exit code when subprocess fails + assert result.exit_code == 1 + # Just verify that the command failed with the right exit code + # The specific error message may vary + +@patch("subprocess.run") +@patch("m3.cli.get_default_database_path") +@patch("m3.cli.get_active_dataset") +def test_config_claude_infers_db_path_demo(mock_active, mock_get_default, mock_subprocess): + mock_active.return_value = None # unset -> default to demo + mock_get_default.return_value = Path("/tmp/inferred-demo.duckdb") + mock_subprocess.return_value = MagicMock(returncode=0) + + result = runner.invoke(app, ["config", "claude"]) + assert result.exit_code == 0 + + # subprocess run should be called with inferred --db-path + call_args = mock_subprocess.call_args[0][0] + assert "--db-path" in call_args + assert "/tmp/inferred-demo.duckdb" in call_args + + # Should have asked for demo duckdb path + mock_get_default.assert_called() + + +@patch("subprocess.run") +@patch("m3.cli.get_default_database_path") +@patch("m3.cli.get_active_dataset") +def test_config_claude_infers_db_path_full(mock_active, mock_get_default, mock_subprocess): + mock_active.return_value = "full" + mock_get_default.return_value = Path("/tmp/inferred-full.duckdb") + mock_subprocess.return_value = MagicMock(returncode=0) + + result = runner.invoke(app, ["config", "claude"]) + assert result.exit_code == 0 + + call_args = mock_subprocess.call_args[0][0] + assert "--db-path" in call_args + assert "/tmp/inferred-full.duckdb" in call_args + +@patch("m3.cli.set_active_dataset") +@patch("m3.cli.detect_available_local_datasets") +def test_use_full_happy_path(mock_detect, mock_set_active): + mock_detect.return_value = { + "demo": { + "parquet_present": False, + "db_present": False, + "parquet_root": "/tmp/demo", + "db_path": "/tmp/demo.duckdb", + }, + "full": { + "parquet_present": True, + "db_present": False, + "parquet_root": "/tmp/full", + "db_path": "/tmp/full.duckdb", + }, + } + + result = runner.invoke(app, ["use", "full"]) + assert result.exit_code == 0 + assert "Active dataset set to 'full'." in result.stdout + mock_set_active.assert_called_once_with("full") + + +@patch("m3.cli.compute_parquet_dir_size", return_value=123) +@patch("m3.cli.get_active_dataset", return_value="full") +@patch("m3.cli.detect_available_local_datasets") +def test_status_happy_path(mock_detect, mock_active, mock_size): + mock_detect.return_value = { + "demo": { + "parquet_present": True, + "db_present": False, + "parquet_root": "/tmp/demo", + "db_path": "/tmp/demo.duckdb", + }, + "full": { + "parquet_present": True, + "db_present": False, + "parquet_root": "/tmp/full", + "db_path": "/tmp/full.duckdb", + }, + } + + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0 + assert "Active dataset: full" in result.stdout + assert "parquet_size_bytes=123" in result.stdout diff --git a/tests/test_config.py b/tests/test_config.py index 30fde3b..a4508ed 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -21,8 +21,8 @@ def test_default_paths(tmp_path, monkeypatch): # Redirect default dirs to a temp location import m3.config as cfg_mod - monkeypatch.setattr(cfg_mod, "DEFAULT_DATABASES_DIR", tmp_path / "dbs") - monkeypatch.setattr(cfg_mod, "DEFAULT_PARQUET_DIR", tmp_path / "parquet") + monkeypatch.setattr(cfg_mod, "_DEFAULT_DATABASES_DIR", tmp_path / "dbs") + monkeypatch.setattr(cfg_mod, "_DEFAULT_PARQUET_DIR", tmp_path / "parquet") db_path = get_default_database_path("mimic-iv-demo", engine="duckdb") raw_path = get_dataset_parquet_root("mimic-iv-demo") # They should be Path objects and exist @@ -35,6 +35,6 @@ def test_default_paths(tmp_path, monkeypatch): def test_raw_path_includes_dataset_name(tmp_path, monkeypatch): import m3.config as cfg_mod - monkeypatch.setattr(cfg_mod, "DEFAULT_PARQUET_DIR", tmp_path / "parquet") + monkeypatch.setattr(cfg_mod, "_DEFAULT_PARQUET_DIR", tmp_path / "parquet") raw_path = get_dataset_parquet_root("mimic-iv-demo") assert "mimic-iv-demo" in str(raw_path) diff --git a/tests/test_data_io.py b/tests/test_data_io.py index 8e753c1..bf605a7 100644 --- a/tests/test_data_io.py +++ b/tests/test_data_io.py @@ -1,6 +1,17 @@ import duckdb +import gzip +import requests +from unittest import mock +import pytest -from m3.data_io import compute_parquet_dir_size, verify_table_rowcount +from m3.data_io import ( + COMMON_USER_AGENT, + _scrape_urls_from_html_page, + compute_parquet_dir_size, + convert_csv_to_parquet, + initialize_duckdb_from_parquet, + verify_table_rowcount, +) def test_compute_parquet_dir_size_empty(tmp_path): @@ -19,3 +30,112 @@ def test_verify_table_rowcount_with_temp_duckdb(tmp_path): count = verify_table_rowcount(db_path, "temp_numbers") assert count == 2 + + +# ------------------------------------------------------------ +# Scraping tests +# ------------------------------------------------------------ + + +class DummyResponse: + def __init__(self, content, status_code=200, headers=None): + self.content = content.encode() + self.status_code = status_code + self.headers = headers or {} + + def raise_for_status(self): + if not (200 <= self.status_code < 300): + raise requests.exceptions.HTTPError(response=self) + + @property + def reason(self): + return "Error" + + def iter_content(self, chunk_size=1): + yield from self.content + + +def test_scrape_urls(monkeypatch): + html = ( + "" + 'ok' + 'no' + "" + ) + dummy = DummyResponse(html) + session = requests.Session() + monkeypatch.setattr(session, "get", lambda url, timeout=None: dummy) + urls = _scrape_urls_from_html_page("http://example.com/", session) + assert urls == ["http://example.com/file1.csv.gz"] + + +def test_scrape_no_matching_suffix(monkeypatch): + html = 'ok' + dummy = DummyResponse(html) + session = requests.Session() + monkeypatch.setattr(session, "get", lambda url, timeout=None: dummy) + urls = _scrape_urls_from_html_page("http://example.com/", session) + assert urls == [] + + +def test_common_user_agent_header(): + # Ensure the constant is set and looks like a UA string + assert isinstance(COMMON_USER_AGENT, str) + assert "Mozilla/" in COMMON_USER_AGENT + + +# ------------------------------------------------------------ +# CSV -> Parquet conversion and DuckDB init tests +# ------------------------------------------------------------ + + +def _write_gz_csv(path, text): + with gzip.open(path, "wt", encoding="utf-8") as f: + f.write(text) + + +def test_convert_csv_to_parquet_and_init_duckdb(tmp_path, monkeypatch): + # Prepare a minimal CSV.gz under hosp/ + src_root = tmp_path / "src" + hosp_dir = src_root / "hosp" + hosp_dir.mkdir(parents=True, exist_ok=True) + csv_gz = hosp_dir / "sample.csv.gz" + + _write_gz_csv( + csv_gz, + "col1,col2\n" # header + "1,foo\n" + "2,bar\n", + ) + + # Convert to Parquet under dst root + dst_root = tmp_path / "parquet" + ok = convert_csv_to_parquet("mimic-iv-demo", src_root, dst_root) + assert ok # conversion succeeded + + out_parquet = dst_root / "hosp" / "sample.parquet" + assert out_parquet.exists() # parquet file created + + # Quick verify via DuckDB + con = duckdb.connect() + try: + cnt = con.execute( + f"SELECT COUNT(*) FROM read_parquet('{out_parquet.as_posix()}')" + ).fetchone()[0] + finally: + con.close() + assert cnt == 2 # two data rows + + # Initialize DuckDB views, patching the parquet root resolver + db_path = tmp_path / "test.duckdb" + with mock.patch("m3.data_io.get_dataset_parquet_root", return_value=dst_root): + init_ok = initialize_duckdb_from_parquet("mimic-iv-demo", db_path) + assert init_ok # views created + + # Query the created view name hosp_sample + con = duckdb.connect(str(db_path)) + try: + cnt = con.execute("SELECT COUNT(*) FROM hosp_sample").fetchone()[0] + finally: + con.close() + assert cnt == 2 diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index d2c0b59..9d63689 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -111,7 +111,7 @@ def test_db(self, tmp_path): ) con.execute( """ - INSERT INTO icu_icustays VALUES + INSERT INTO icu_icustays (subject_id, hadm_id, stay_id, intime, outtime) VALUES (10000032, 20000001, 30000001, '2180-07-23 15:00:00', '2180-07-24 12:00:00'), (10000033, 20000002, 30000002, '2180-08-15 10:30:00', '2180-08-16 14:15:00') """ @@ -129,7 +129,7 @@ def test_db(self, tmp_path): ) con.execute( """ - INSERT INTO hosp_labevents VALUES + INSERT INTO hosp_labevents (subject_id, hadm_id, itemid, charttime, value) VALUES (10000032, 20000001, 50912, '2180-07-23 16:00:00', '120'), (10000033, 20000002, 50912, '2180-08-15 11:00:00', '95') """ From 5667466c7030d6825762130c2aae4baeedb56c76 Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 5 Nov 2025 19:41:18 -0500 Subject: [PATCH 06/13] Merge CLI commands download and convert into init --- README.md | 31 ++-- src/m3/cli.py | 319 ++++++++++++++++-------------------------- src/m3/data_io.py | 2 +- tests/test_cli.py | 5 +- tests/test_data_io.py | 4 +- 5 files changed, 135 insertions(+), 226 deletions(-) diff --git a/README.md b/README.md index fd846eb..c116f81 100644 --- a/README.md +++ b/README.md @@ -270,20 +270,12 @@ After installation, choose your data source: **Perfect for learning and development - completely free!** -1. **Download demo CSVs**: - ```bash - m3 download mimic-iv-demo - ``` -2. **Convert CSV to Parquet**: - ```bash - m3 convert mimic-iv-demo - ``` -3. **Create DuckDB views over Parquet**: +1. **Initialize demo dataset**: ```bash m3 init mimic-iv-demo ``` -4. **Setup MCP Client**: +2. **Setup MCP Client**: ```bash m3 config ``` @@ -306,14 +298,13 @@ After installation, choose your data source: - Download the official MIMIC-IV CSVs from PhysioNet and place them under: - `/Users/you/path/to/m3/m3_data/raw_files/mimic-iv-full/hosp/` - `/Users/you/path/to/m3/m3_data/raw_files/mimic-iv-full/icu/` - - Note: `m3 download` currently supports the demo only. Use your browser or `wget` to obtain the full dataset. + - Note: `m3 init`'s auto-download function currently only supports the demo dataset. Use your browser or `wget` to obtain the full dataset. -2. **Convert CSV → Parquet** (streaming via DuckDB): +2. **Initialize full dataset**: ```bash - m3 convert mimic-iv-full + m3 init mimic-iv-full ``` - This may take up to 30 minutes, depending on your system (e.g. 10 minutes for MacBook Pro M3) - - Default destination: `/Users/you/path/to/m3/m3_data/parquet/mimic-iv-full/` - Performance knobs (optional): ```bash export M3_CONVERT_MAX_WORKERS=6 # number of parallel files (default=4) @@ -322,20 +313,14 @@ After installation, choose your data source: ``` Pay attention to your system specifications, especially if you have enough memory. -3. **Create DuckDB views over Parquet**: - ```bash - m3 init mimic-iv-full - ``` - - Database path: `/Users/you/path/to/m3/m3_data/databases/mimic_iv_full.duckdb` - -4. **Select dataset and verify**: +3. **Select dataset and verify**: ```bash - m3 use full + m3 use full # optional, as this automatically got set to full m3 status ``` - Status prints active dataset, local DB path, Parquet presence, quick row counts and total Parquet size. -5. **Configure MCP client** (uses the full local DB): +4. **Configure MCP client** (uses the full local DB): ```bash m3 config # or diff --git a/src/m3/cli.py b/src/m3/cli.py index 0b7fb32..239f318 100644 --- a/src/m3/cli.py +++ b/src/m3/cli.py @@ -21,7 +21,7 @@ compute_parquet_dir_size, convert_csv_to_parquet, download_dataset, - initialize_duckdb_from_parquet, + init_duckdb_from_parquet, verify_table_rowcount, ) @@ -86,6 +86,15 @@ def dataset_init_cmd( metavar="DATASET_NAME", ), ] = "mimic-iv-demo", + src: Annotated[ + str | None, + typer.Option( + "--src", + help=( + "Path to existing raw CSV.gz root (hosp/, icu/). If provided, download is skipped." + ), + ), + ] = None, db_path_str: Annotated[ str | None, typer.Option( @@ -96,16 +105,20 @@ def dataset_init_cmd( ] = None, ): """ - Initialize a local dataset by creating DuckDB views over existing Parquet files. - - - Parquet must already exist under /m3_data/parquet// - - DuckDB file will be at /m3_data/databases/.duckdb + Initialize a local dataset in one step by detecting what's already present: + - If Parquet exists: only initialize DuckDB views + - If raw CSV.gz exists but Parquet is missing: convert then initialize + - If neither exists: download (demo only), convert, then initialize + + Notes: + - Auto-download currently supports only 'mimic-iv-demo'. For 'mimic-iv-full', + place the official raw CSV.gz files under /m3_data/raw_files// + with 'hosp/' and 'icu/' subdirectories, then re-run this command. """ logger.info(f"CLI 'init' called for dataset: '{dataset_name}'") - dataset_key = dataset_name.lower() # Normalize for lookup + dataset_key = dataset_name.lower() dataset_config = get_dataset_config(dataset_key) - if not dataset_config: typer.secho( f"Error: Dataset '{dataset_name}' is not supported or not configured.", @@ -119,6 +132,80 @@ def dataset_init_cmd( ) raise typer.Exit(code=1) + # Resolve roots + pq_root = get_dataset_parquet_root(dataset_key) + if pq_root is None: + typer.secho("Could not determine dataset directories.", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + + csv_root_default = pq_root.parent.parent / "raw_files" / dataset_key + csv_root = Path(src).resolve() if src else csv_root_default + + # Presence detection (check for any parquet or csv.gz files) + # NOTE: Checks need to be more robust as soon as we support the full dataset for download (don't just check for any file, but that no files are missing) + parquet_present = any(pq_root.rglob("*.parquet")) + raw_present = any(csv_root.rglob("*.csv.gz")) + + typer.echo(f"Detected dataset: '{dataset_key}'") + typer.echo(f"Raw root: {csv_root} (present={raw_present})") + typer.echo(f"Parquet root: {pq_root} (present={parquet_present})") + + # Step 1: Ensure raw dataset exists (download demo if missing; for full, inform and return) + if not raw_present and not parquet_present: + if dataset_key == "mimic-iv-demo": + out_dir = csv_root_default + out_dir.mkdir(parents=True, exist_ok=True) + + typer.echo(f"Downloading dataset: '{dataset_key}'") + typer.echo(f"Listing URL: {dataset_config.get('file_listing_url')}") + typer.echo(f"Output directory: {out_dir}") + + ok = download_dataset(dataset_key, out_dir) + if not ok: + typer.secho( + "Download failed. Please check logs for details.", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=1) + typer.secho("✅ Download complete.", fg=typer.colors.GREEN) + + # Point csv_root to the downloaded location + csv_root = out_dir + raw_present = True + else: + typer.secho( + "Auto-download is only supported for 'mimic-iv-demo'.", + fg=typer.colors.YELLOW, + ) + typer.secho( + ( + "To initialize 'mimic-iv-full':\n" + "1) Download the official MIMIC-IV dataset from PhysioNet (this requires a PhysioNet account with dataset access)\n" + "2) Place the raw CSV.gz files under: {csv_root_default}\n" + " Ensure the structure includes 'hosp/' and 'icu/' subdirectories.\n" + "3) Then re-run: m3 init mimic-iv-full" + ), + fg=typer.colors.WHITE, + ) + return + + # Step 2: Ensure Parquet exists (convert if missing) + if not parquet_present: + typer.echo(f"Converting dataset: '{dataset_key}'") + typer.echo(f"CSV root: {csv_root}") + typer.echo(f"Parquet destination: {pq_root}") + ok = convert_csv_to_parquet(dataset_key, csv_root, pq_root) + if not ok: + typer.secho( + "Conversion failed. Please check logs for details.", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=1) + typer.secho("✅ Conversion complete.", fg=typer.colors.GREEN) + + # Step 2: Initialize DuckDB over Parquet final_db_path = ( Path(db_path_str).resolve() if db_path_str @@ -132,26 +219,21 @@ def dataset_init_cmd( ) raise typer.Exit(code=1) - # Ensure parent directory for the database exists final_db_path.parent.mkdir(parents=True, exist_ok=True) - parquet_root = get_dataset_parquet_root(dataset_key) typer.echo(f"Initializing dataset: '{dataset_name}'") typer.echo(f"DuckDB path: {final_db_path}") - typer.echo(f"Parquet root: {parquet_root}") + typer.echo(f"Parquet root: {pq_root}") - if not parquet_root or not parquet_root.exists(): + if not pq_root or not pq_root.exists(): typer.secho( - f"Parquet directory not found at {parquet_root}.\n", + f"Parquet directory not found at {pq_root}.", fg=typer.colors.RED, err=True, ) raise typer.Exit(code=1) - initialization_successful = initialize_duckdb_from_parquet( - dataset_name=dataset_key, db_target_path=final_db_path - ) - - if not initialization_successful: + init_successful = init_duckdb_from_parquet(dataset_name=dataset_key, db_target_path=final_db_path) + if not init_successful: typer.secho( ( f"Dataset '{dataset_name}' initialization FAILED. " @@ -167,45 +249,39 @@ def dataset_init_cmd( "Verifying database integrity..." ) - # Basic verification by querying a known table (fast check) verification_table_name = dataset_config.get("primary_verification_table") if not verification_table_name: logger.warning( - f"No 'primary_verification_table' configured for '{dataset_name}'. " - "Skipping DB query test." + f"No 'primary_verification_table' configured for '{dataset_name}'. Skipping DB query test." ) typer.secho( ( f"Dataset '{dataset_name}' initialized to {final_db_path}. " - f"Parquet at {parquet_root}." + f"Parquet at {pq_root}." ), fg=typer.colors.GREEN, ) - typer.secho( - "Skipped database query test as no verification table is set in config.", - fg=typer.colors.YELLOW, - ) - return + else: + try: + record_count = verify_table_rowcount(final_db_path, verification_table_name) + typer.secho( + f"Database verification successful: Found {record_count} records in table '{verification_table_name}'.", + fg=typer.colors.GREEN, + ) + typer.secho( + f"Dataset '{dataset_name}' ready at {final_db_path}. Parquet at {pq_root}.", + fg=typer.colors.BRIGHT_GREEN, + ) + except Exception as e: + logger.error( + f"Unexpected error during database verification: {e}", exc_info=True + ) + typer.secho( + f"An unexpected error occurred during database verification: {e}", + fg=typer.colors.RED, + err=True, + ) - try: - record_count = verify_table_rowcount(final_db_path, verification_table_name) - typer.secho( - f"Database verification successful: Found {record_count} records in table '{verification_table_name}'.", - fg=typer.colors.GREEN, - ) - typer.secho( - f"Dataset '{dataset_name}' ready at {final_db_path}. Parquet at {parquet_root}.", - fg=typer.colors.BRIGHT_GREEN, - ) - except Exception as e: - logger.error( - f"Unexpected error during database verification: {e}", exc_info=True - ) - typer.secho( - f"An unexpected error occurred during database verification: {e}", - fg=typer.colors.RED, - err=True, - ) # Set active dataset to match init target if dataset_key == "mimic-iv-demo": set_active_dataset("demo") @@ -507,158 +583,5 @@ def config_cmd( raise typer.Exit(code=1) -@app.command("download") -def download_cmd( - dataset: Annotated[ - str, - typer.Argument( - help=( - "Dataset to download (currently only supports 'mimic-iv-demo'). " - f"Configured: {', '.join(SUPPORTED_DATASETS.keys())}" - ), - metavar="DATASET_NAME", - ), - ], - output: Annotated[ - str | None, - typer.Option( - "--output", - "-o", - help=( - "Directory to store downloaded files. Default: /m3_data/raw_files//" - ), - ), - ] = None, -): - """ - Download public dataset files (demo only in this version). - - - For 'mimic-iv-demo', downloads CSV.gz files under hosp/ and icu/ subdirectories. - - Files are saved to the output directory, preserving the original structure. - - This command does not convert CSV to Parquet. - """ - dataset_key = dataset.lower() - if dataset_key != "mimic-iv-demo": - typer.secho( - "Currently only 'mimic-iv-demo' is supported by 'm3 download'.", - fg=typer.colors.RED, - err=True, - ) - raise typer.Exit(code=1) - - cfg = get_dataset_config(dataset_key) - if not cfg or not cfg.get("file_listing_url"): - typer.secho( - f"Dataset '{dataset}' is not configured for download.", - fg=typer.colors.RED, - err=True, - ) - raise typer.Exit(code=1) - - # Default output: /m3_data/raw_files// - if output: - out_dir = Path(output).resolve() - else: - # Build from the parquet root: /m3_data/parquet/ -> /m3_data/raw_files/ - pq = get_dataset_parquet_root(dataset_key) - out_dir = pq.parent.parent / "raw_files" / dataset_key - - out_dir.mkdir(parents=True, exist_ok=True) - - typer.echo(f"Downloading dataset: '{dataset}'") - typer.echo(f"Listing URL: {cfg.get('file_listing_url')}") - typer.echo(f"Output directory: {out_dir}") - - ok = download_dataset(dataset_key, out_dir) - if not ok: - typer.secho( - "Download failed. Please check logs for details.", - fg=typer.colors.RED, - err=True, - ) - raise typer.Exit(code=1) - - typer.secho("✅ Download complete.", fg=typer.colors.GREEN) - typer.secho( - "Next step: Run 'm3 convert' to convert the downloaded CSV files to Parquet.", - fg=typer.colors.YELLOW, - ) - - -@app.command("convert") -def convert_cmd( - dataset: Annotated[ - str, - typer.Argument( - help=( - "Dataset to convert (csv.gz → parquet). Expected CSVs under raw_files/." - ), - metavar="DATASET_NAME", - ), - ], - src: Annotated[ - str | None, - typer.Option( - "--src", - "-s", - help="Root directory containing CSV.gz files (default: /m3_data/raw_files/)", - ), - ] = None, - dst: Annotated[ - str | None, - typer.Option( - "--dst", - "-d", - help="Destination Parquet root (default: /m3_data/parquet/)", - ), - ] = None, -): - """ - Convert all CSV.gz files for a dataset to Parquet, mirroring structure (hosp/, icu/). - Uses DuckDB streaming COPY for low memory usage. - """ - dataset_key = dataset.lower() - cfg = get_dataset_config(dataset_key) - if not cfg: - typer.secho( - f"Unsupported dataset: {dataset}", - fg=typer.colors.RED, - err=True, - ) - raise typer.Exit(code=1) - - # Defaults - pq_root_default = get_dataset_parquet_root(dataset_key) - # raw_files default relative to project root - if pq_root_default is None: - typer.secho("Could not determine dataset directories.", fg=typer.colors.RED, err=True) - raise typer.Exit(code=1) - - if src: - csv_root = Path(src).resolve() - else: - csv_root = pq_root_default.parent.parent / "raw_files" / dataset_key - - if dst: - parquet_root = Path(dst).resolve() - else: - parquet_root = pq_root_default - - typer.echo(f"Converting dataset: '{dataset}'") - typer.echo(f"CSV root: {csv_root}") - typer.echo(f"Parquet destination: {parquet_root}") - - ok = convert_csv_to_parquet(dataset_key, csv_root, parquet_root) - if not ok: - typer.secho( - "Conversion failed. Please check logs for details.", - fg=typer.colors.RED, - err=True, - ) - raise typer.Exit(code=1) - - typer.secho("✅ Conversion complete.", fg=typer.colors.GREEN) - - if __name__ == "__main__": app() diff --git a/src/m3/data_io.py b/src/m3/data_io.py index 995f02c..853bf9f 100644 --- a/src/m3/data_io.py +++ b/src/m3/data_io.py @@ -323,7 +323,7 @@ def convert_csv_to_parquet(dataset_name: str, csv_root: Path, parquet_root: Path # DuckDB functions ######################################################## -def initialize_duckdb_from_parquet(dataset_name: str, db_target_path: Path) -> bool: +def init_duckdb_from_parquet(dataset_name: str, db_target_path: Path) -> bool: """ Initialize or refresh a DuckDB for the dataset by creating views over Parquet. diff --git a/tests/test_cli.py b/tests/test_cli.py index 410b4db..e26881a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -44,7 +44,7 @@ def test_unknown_command_reports_error(): ) -@patch("m3.cli.initialize_duckdb_from_parquet") +@patch("m3.cli.init_duckdb_from_parquet") @patch("m3.cli.verify_table_rowcount") def test_init_command_duckdb_custom_path(mock_rowcount, mock_init): """Test that m3 init --db-path uses custom database path override and DuckDB flow.""" @@ -229,4 +229,5 @@ def test_status_happy_path(mock_detect, mock_active, mock_size): result = runner.invoke(app, ["status"]) assert result.exit_code == 0 assert "Active dataset: full" in result.stdout - assert "parquet_size_bytes=123" in result.stdout + size_gb = 123 / (1024 ** 3) + assert f"parquet_size_gb: {size_gb:.4f} GB" in result.stdout diff --git a/tests/test_data_io.py b/tests/test_data_io.py index bf605a7..1df838c 100644 --- a/tests/test_data_io.py +++ b/tests/test_data_io.py @@ -9,7 +9,7 @@ _scrape_urls_from_html_page, compute_parquet_dir_size, convert_csv_to_parquet, - initialize_duckdb_from_parquet, + init_duckdb_from_parquet, verify_table_rowcount, ) @@ -129,7 +129,7 @@ def test_convert_csv_to_parquet_and_init_duckdb(tmp_path, monkeypatch): # Initialize DuckDB views, patching the parquet root resolver db_path = tmp_path / "test.duckdb" with mock.patch("m3.data_io.get_dataset_parquet_root", return_value=dst_root): - init_ok = initialize_duckdb_from_parquet("mimic-iv-demo", db_path) + init_ok = init_duckdb_from_parquet("mimic-iv-demo", db_path) assert init_ok # views created # Query the created view name hosp_sample From 4e097390b799b7f3535228f9a6c7ad41e0b3ae36 Mon Sep 17 00:00:00 2001 From: hill Date: Sat, 15 Nov 2025 17:52:48 -0500 Subject: [PATCH 07/13] Update README and update config --- README.md | 44 ++++++++----------- src/m3/cli.py | 23 +++++----- src/m3/config.py | 32 ++++++-------- .../mcp_client_configs/dynamic_mcp_config.py | 30 ++++++++----- src/m3/mcp_server.py | 2 +- tests/test_config.py | 2 +- 6 files changed, 65 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index c116f81..cf2d458 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,7 @@ uv --version ``` *Opens your browser - choose the Google account with BigQuery access to MIMIC-IV.* -### MCP Client Configuration - -Paste one of the following into your MCP client config, then restart your client. +### M3 Initialization **Supported clients:** [Claude Desktop](https://www.claude.com/download), [Cursor](https://cursor.com/download), [Goose](https://block.github.io/goose/), and [more](https://github.com/punkpeye/awesome-mcp-clients). @@ -69,25 +67,24 @@ Paste one of the following into your MCP client config, then restart your client -**DuckDB (Demo Database)** +**DuckDB (Demo or Full Dataset)** -Free, local, no setup required. -```json -{ - "mcpServers": { - "m3": { - "command": "uvx", - "args": ["m3-mcp"], - "env": { - "M3_BACKEND": "duckdb" - } - } - } -} +To create a m3 directory and navigate into it run: +```shell +mkdir m3 && cd m3 +``` +If you want to use the full dataset, download it manually from [PhysioNet](https://physionet.org/content/mimiciv/3.1/) and place it into `m3/m3_data/raw`. For using the demo set you can continue and run: + +```shell +uv init && uv add m3-mcp && \ +uv run m3 init DATASET_NAME && uv run m3 config --quick ``` +Replace `DATASET_NAME` with `mimic-iv-demo` or `mimic-iv-full` and copy & paste the output of this command into your client config JSON file. -*Demo database (136MB, 100 patients, 275 admissions) downloads automatically on first query.* +*Demo dataset (16MB raw download size) downloads automatically on first query.* + +*Full dataset (10.6GB raw download size) needs to be downloaded manually.* @@ -96,6 +93,8 @@ Free, local, no setup required. Requires GCP credentials and PhysioNet access. +Paste this into your client config JSON file: + ```json { "mcpServers": { @@ -479,14 +478,7 @@ Try asking your MCP client these questions: ### Common Issues **Local "Parquet not found" or view errors:** -```bash -# 1) Download CSVs (demo only in current version) -m3 download mimic-iv-demo -# 2) Convert CSV → Parquet -m3 convert mimic-iv-demo -# 3) Create/refresh views -m3 init mimic-iv-demo -``` +Rerun the `m3 init` command for your chosen dataset. **MCP client server not starting:** 1. Check your MCP client logs (for Claude Desktop: Help → View Logs) diff --git a/src/m3/cli.py b/src/m3/cli.py index 239f318..7a6ce51 100644 --- a/src/m3/cli.py +++ b/src/m3/cli.py @@ -209,7 +209,7 @@ def dataset_init_cmd( final_db_path = ( Path(db_path_str).resolve() if db_path_str - else get_default_database_path(dataset_key, "duckdb") + else get_default_database_path(dataset_key) ) if not final_db_path: typer.secho( @@ -484,16 +484,17 @@ def config_cmd( # For duckdb, infer db_path from active dataset if not provided if backend == "duckdb": - effective_db_path = db_path - if not effective_db_path: - active = get_active_dataset() - # Default to demo if unset - dataset_key = "mimic-iv-full" if active == "full" else "mimic-iv-demo" - guessed = get_default_database_path(dataset_key, "duckdb") - if guessed is not None: - effective_db_path = str(guessed) - if effective_db_path: - cmd.extend(["--db-path", effective_db_path]) + if db_path: + inferred_db_path = Path(db_path).resolve() + else: + active_dataset = get_active_dataset() + if not active_dataset: + # default to demo if nothing is set + inferred_db_path = get_default_database_path("mimic-iv-demo") + else: + inferred_db_path = get_default_database_path(active_dataset) + cmd.extend(["--db-path", str(inferred_db_path)]) + elif backend == "bigquery" and project_id: cmd.extend(["--project-id", project_id]) diff --git a/src/m3/config.py b/src/m3/config.py index 93489eb..a8522e7 100644 --- a/src/m3/config.py +++ b/src/m3/config.py @@ -40,7 +40,6 @@ def _get_project_root() -> Path: _DEFAULT_PARQUET_DIR = _PROJECT_DATA_DIR / "parquet" _RUNTIME_CONFIG_PATH = _PROJECT_DATA_DIR / "config.json" - # -------------------------------------------------- # Dataset configurations (add more entries as needed) # -------------------------------------------------- @@ -48,14 +47,12 @@ def _get_project_root() -> Path: "mimic-iv-demo": { "file_listing_url": "https://physionet.org/files/mimic-iv-demo/2.2/", "subdirectories_to_scan": ["hosp", "icu"], - "default_db_filename": "mimic_iv_demo.db", "default_duckdb_filename": "mimic_iv_demo.duckdb", "primary_verification_table": "hosp_admissions", }, "mimic-iv-full": { "file_listing_url": None, "subdirectories_to_scan": ["hosp", "icu"], - "default_db_filename": "mimic_iv_full.db", "default_duckdb_filename": "mimic_iv_full.duckdb", "primary_verification_table": "hosp_admissions", }, @@ -76,14 +73,11 @@ def get_dataset_config(dataset_name: str) -> dict | None: return SUPPORTED_DATASETS.get(dataset_name.lower()) -def get_default_database_path(dataset_name: str, engine: Literal["duckdb", "bigquery"] = "duckdb") -> Path | None: +def get_default_database_path(dataset_name: str) -> Path | None: """ Return the default local DuckDB path for a given dataset, under /m3_data/databases/. """ - if engine != "duckdb": - logger.warning("Only DuckDB is supported for local databases.") - return None cfg = get_dataset_config(dataset_name) if not cfg: @@ -120,12 +114,8 @@ def _ensure_data_dirs(): _PROJECT_DATA_DIR.mkdir(parents=True, exist_ok=True) -def get_runtime_config_path() -> Path: - return _RUNTIME_CONFIG_PATH - - - -_DEFAULT_RUNTIME_CONFIG = { +def _get_default_runtime_config() -> dict: + return { "active_dataset": None, "duckdb_paths": { "demo": str(get_default_database_path("mimic-iv-demo") or ""), @@ -139,7 +129,7 @@ def get_runtime_config_path() -> Path: def load_runtime_config() -> dict: - """Load runtime configuration from /m3_data/config.json.""" + """Load runtime configuration from /m3_data/config.json or use default""" _ensure_data_dirs() if _RUNTIME_CONFIG_PATH.exists(): try: @@ -147,7 +137,7 @@ def load_runtime_config() -> dict: except Exception: logger.warning("Could not parse runtime config; using defaults") # defaults - return _DEFAULT_RUNTIME_CONFIG + return _get_default_runtime_config() def save_runtime_config(cfg: dict) -> None: @@ -185,14 +175,18 @@ def detect_available_local_datasets() -> dict: def get_active_dataset() -> str | None: cfg = load_runtime_config() active = cfg.get("active_dataset") - if active: - return active + if active in CLI_DATASET_ALIASES: + return CLI_DATASET_ALIASES[active] + if active == "bigquery": + return "bigquery" # Auto-detect default: prefer demo, then full availability = detect_available_local_datasets() if availability["demo"]["parquet_present"]: - return "demo" + return CLI_DATASET_ALIASES["demo"] if availability["full"]["parquet_present"]: - return "full" + return CLI_DATASET_ALIASES["full"] + + logger.warning("Unknown active_dataset value in config: %s", active) return None diff --git a/src/m3/mcp_client_configs/dynamic_mcp_config.py b/src/m3/mcp_client_configs/dynamic_mcp_config.py index 5586a7b..179d0b3 100644 --- a/src/m3/mcp_client_configs/dynamic_mcp_config.py +++ b/src/m3/mcp_client_configs/dynamic_mcp_config.py @@ -10,6 +10,8 @@ from pathlib import Path from typing import Any +from m3.config import get_active_dataset, get_default_database_path + # Error messages _DATABASE_PATH_ERROR_MSG = ( "Could not determine default database path for mimic-iv-demo.\n" @@ -81,8 +83,21 @@ def generate_config( } # Add backend-specific environment variables - if backend in ("sqlite", "duckdb") and db_path: - env["M3_DB_PATH"] = db_path + if backend == "duckdb": + if db_path: + env["M3_DB_PATH"] = db_path + else: + active = get_active_dataset() + if not active: + raise ValueError( + "Could not determine default DuckDB path; run `m3 init ...` first " + "or pass --db-path explicitly." + ) + default_path = get_default_database_path(active) + if not default_path: + raise ValueError(_DATABASE_PATH_ERROR_MSG) + env["M3_DB_PATH"] = str(default_path) + elif backend == "bigquery" and project_id: env["M3_PROJECT_ID"] = project_id env["GOOGLE_CLOUD_PROJECT"] = project_id @@ -174,9 +189,7 @@ def interactive_config(self) -> dict[str, Any]: if backend == "duckdb": print("\n📁 DuckDB Configuration:") - from m3.config import get_default_database_path - - default_db_path = get_default_database_path("mimic-iv-demo", engine="duckdb") + default_db_path = get_default_database_path("mimic-iv-demo") if default_db_path is None: raise ValueError(_DATABASE_PATH_ERROR_MSG) print(f"Default database path: {default_db_path}") @@ -282,11 +295,8 @@ def print_config_info(config: dict[str, Any]): if "M3_DB_PATH" in server_config["env"]: print(f"💾 Database path: {server_config['env']['M3_DB_PATH']}") elif server_config["env"].get("M3_BACKEND") in ("duckdb",): - # Show the default path when using SQLite or DuckDB backend - from m3.config import get_default_database_path - engine = server_config["env"].get("M3_BACKEND") - - default_path = get_default_database_path("mimic-iv-demo", engine=engine) + # Show the default path when using DuckDB backend + default_path = get_default_database_path("mimic-iv-demo") if default_path is None: raise ValueError(_DATABASE_PATH_ERROR_MSG) print(f"💾 Database path: {default_path}") diff --git a/src/m3/mcp_server.py b/src/m3/mcp_server.py index 27538cf..97e4fad 100644 --- a/src/m3/mcp_server.py +++ b/src/m3/mcp_server.py @@ -141,7 +141,7 @@ def _init_backend(): if _backend == "duckdb": _db_path = os.getenv("M3_DB_PATH") if not _db_path: - path = get_default_database_path("mimic-iv-demo", engine="duckdb") + path = get_default_database_path("mimic-iv-demo") _db_path = str(path) if path else None if not _db_path or not Path(_db_path).exists(): raise FileNotFoundError(f"DuckDB database not found: {_db_path}") diff --git a/tests/test_config.py b/tests/test_config.py index a4508ed..c317c04 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -23,7 +23,7 @@ def test_default_paths(tmp_path, monkeypatch): monkeypatch.setattr(cfg_mod, "_DEFAULT_DATABASES_DIR", tmp_path / "dbs") monkeypatch.setattr(cfg_mod, "_DEFAULT_PARQUET_DIR", tmp_path / "parquet") - db_path = get_default_database_path("mimic-iv-demo", engine="duckdb") + db_path = get_default_database_path("mimic-iv-demo") raw_path = get_dataset_parquet_root("mimic-iv-demo") # They should be Path objects and exist assert isinstance(db_path, Path) From 5df59ece7a659545590f178925c038661e0c6cb6 Mon Sep 17 00:00:00 2001 From: Hannes Ill <54182904+hannesill@users.noreply.github.com> Date: Sun, 16 Nov 2025 16:28:35 -0500 Subject: [PATCH 08/13] Remove 2nd backend comparison from README --- README.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/README.md b/README.md index cf2d458..f11204d 100644 --- a/README.md +++ b/README.md @@ -422,20 +422,6 @@ m3 config # Choose OAuth2 option during setup --- -## Backend Details - -**DuckDB Backend (Local)** -- ✅ **Free**: No cloud costs -- ✅ **Fast**: Local queries over Parquet -- ✅ **Easy**: No authentication needed -- ❌ **Big download size**: Manual download for the full dataset required - -**BigQuery Backend** -- ✅ **Complete**: Full MIMIC-IV dataset (~500k admissions) -- ✅ **Scalable**: Google Cloud infrastructure -- ✅ **Current**: Latest MIMIC-IV version (3.1) -- ❌ **Costs**: BigQuery usage fees apply - ## 🛠️ Available MCP Tools When your MCP client processes questions, it uses these tools automatically: From 927b38c0cebbfa7a3d0eb474a5aa8ee03e7be0e5 Mon Sep 17 00:00:00 2001 From: hill Date: Tue, 18 Nov 2025 15:08:51 -0500 Subject: [PATCH 09/13] Remove local path reference --- tests/test_cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index e26881a..32d01f5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -54,10 +54,10 @@ def test_init_command_duckdb_custom_path(mock_rowcount, mock_init): with tempfile.TemporaryDirectory() as temp_dir: custom_db_path = Path(temp_dir) / "custom_mimic.duckdb" resolved_custom_db_path = custom_db_path.resolve() - - # Also ensure a dummy parquet path exists for the dataset discovery + # Also ensure a deterministic parquet path exists for the dataset discovery. with patch("m3.cli.get_dataset_parquet_root") as mock_parquet_root: - mock_parquet_root.return_value = Path("/Users/hannesill/Developer/m3/m3_data/parquet/mimic-iv-demo/") + repo_root = Path(__file__).resolve().parents[1] + mock_parquet_root.return_value = repo_root / "m3_data/parquet/mimic-iv-demo" with patch.object(Path, "exists", return_value=True): result = runner.invoke( app, ["init", "mimic-iv-demo", "--db-path", str(custom_db_path)] From 3a47c2c40fa82aa6f3ed04538eb6685e1476378b Mon Sep 17 00:00:00 2001 From: hill Date: Tue, 18 Nov 2025 15:34:09 -0500 Subject: [PATCH 10/13] Run pre-commit --- src/m3/cli.py | 47 ++++++++++++---- src/m3/config.py | 56 +++++++++++++------ src/m3/data_io.py | 25 ++++++--- .../mcp_client_configs/dynamic_mcp_config.py | 2 +- .../setup_claude_desktop.py | 4 +- src/m3/mcp_server.py | 5 +- tests/test_cli.py | 14 +++-- tests/test_data_io.py | 6 +- tests/test_mcp_server.py | 3 +- 9 files changed, 114 insertions(+), 48 deletions(-) diff --git a/src/m3/cli.py b/src/m3/cli.py index 7a6ce51..cc7a4dc 100644 --- a/src/m3/cli.py +++ b/src/m3/cli.py @@ -135,7 +135,9 @@ def dataset_init_cmd( # Resolve roots pq_root = get_dataset_parquet_root(dataset_key) if pq_root is None: - typer.secho("Could not determine dataset directories.", fg=typer.colors.RED, err=True) + typer.secho( + "Could not determine dataset directories.", fg=typer.colors.RED, err=True + ) raise typer.Exit(code=1) csv_root_default = pq_root.parent.parent / "raw_files" / dataset_key @@ -232,7 +234,9 @@ def dataset_init_cmd( ) raise typer.Exit(code=1) - init_successful = init_duckdb_from_parquet(dataset_name=dataset_key, db_target_path=final_db_path) + init_successful = init_duckdb_from_parquet( + dataset_name=dataset_key, db_target_path=final_db_path + ) if not init_successful: typer.secho( ( @@ -293,13 +297,17 @@ def dataset_init_cmd( def use_cmd( target: Annotated[ str, - typer.Argument(help="Select active dataset: demo | full | bigquery", metavar="TARGET"), - ] + typer.Argument( + help="Select active dataset: demo | full | bigquery", metavar="TARGET" + ), + ], ): """Set the active dataset selection for the project.""" target = target.lower() if target not in ("demo", "full", "bigquery"): - typer.secho("Target must be one of: demo, full, bigquery", fg=typer.colors.RED, err=True) + typer.secho( + "Target must be one of: demo, full, bigquery", fg=typer.colors.RED, err=True + ) raise typer.Exit(code=1) if target in ("demo", "full"): @@ -320,7 +328,10 @@ def use_cmd( def status_cmd(): """Show active dataset, local DB path, Parquet presence, quick counts and sizes.""" active = get_active_dataset() or "(unset)" - typer.secho(f"Active dataset: {active}", fg=typer.colors.BRIGHT_GREEN if active != "(unset)" else typer.colors.YELLOW) + typer.secho( + f"Active dataset: {active}", + fg=typer.colors.BRIGHT_GREEN if active != "(unset)" else typer.colors.YELLOW, + ) availability = detect_available_local_datasets() @@ -338,7 +349,7 @@ def status_cmd(): if info["parquet_present"]: try: size_bytes = compute_parquet_dir_size(Path(info["parquet_root"])) - size_gb = float(size_bytes) / (1024 ** 3) + size_gb = float(size_bytes) / (1024**3) typer.echo(f" parquet_size_gb: {size_gb:.4f} GB") except Exception: typer.echo(" parquet_size_gb: (skipped)") @@ -348,7 +359,9 @@ def status_cmd(): cfg = get_dataset_config(ds_name) if info["db_present"] and cfg: try: - count = verify_table_rowcount(Path(info["db_path"]), cfg["primary_verification_table"]) + count = verify_table_rowcount( + Path(info["db_path"]), cfg["primary_verification_table"] + ) typer.echo(f" {cfg['primary_verification_table']}_rowcount: {count:,}") except Exception: typer.echo(" rowcount: (skipped)") @@ -452,15 +465,27 @@ def config_cmd( # Validate backend-specific arguments # duckdb: db_path allowed, project_id not allowed if backend == "duckdb" and project_id: - typer.secho("❌ Error: --project-id can only be used with --backend bigquery", fg=typer.colors.RED, err=True) + typer.secho( + "❌ Error: --project-id can only be used with --backend bigquery", + fg=typer.colors.RED, + err=True, + ) raise typer.Exit(code=1) # bigquery: requires project_id, db_path not allowed if backend == "bigquery" and db_path: - typer.secho("❌ Error: --db-path can only be used with --backend duckdb", fg=typer.colors.RED, err=True) + typer.secho( + "❌ Error: --db-path can only be used with --backend duckdb", + fg=typer.colors.RED, + err=True, + ) raise typer.Exit(code=1) if backend == "bigquery" and not project_id: - typer.secho("❌ Error: --project-id is required when using --backend bigquery", fg=typer.colors.RED, err=True) + typer.secho( + "❌ Error: --project-id is required when using --backend bigquery", + fg=typer.colors.RED, + err=True, + ) raise typer.Exit(code=1) if client == "claude": diff --git a/src/m3/config.py b/src/m3/config.py index a8522e7..fd094e7 100644 --- a/src/m3/config.py +++ b/src/m3/config.py @@ -1,7 +1,6 @@ import json import logging from pathlib import Path -from typing import Literal APP_NAME = "m3" @@ -81,7 +80,9 @@ def get_default_database_path(dataset_name: str) -> Path | None: cfg = get_dataset_config(dataset_name) if not cfg: - logger.warning(f"Unknown dataset, cannot determine default DB path: {dataset_name}") + logger.warning( + f"Unknown dataset, cannot determine default DB path: {dataset_name}" + ) return None _DEFAULT_DATABASES_DIR.mkdir(parents=True, exist_ok=True) @@ -91,6 +92,7 @@ def get_default_database_path(dataset_name: str) -> Path | None: return None return _DEFAULT_DATABASES_DIR / db_fname + def get_dataset_parquet_root(dataset_name: str) -> Path | None: """ Return the Parquet root for a dataset under @@ -98,7 +100,9 @@ def get_dataset_parquet_root(dataset_name: str) -> Path | None: """ cfg = get_dataset_config(dataset_name) if not cfg: - logger.warning(f"Unknown dataset, cannot determine Parquet root: {dataset_name}") + logger.warning( + f"Unknown dataset, cannot determine Parquet root: {dataset_name}" + ) return None path = _DEFAULT_PARQUET_DIR / dataset_name.lower() path.mkdir(parents=True, exist_ok=True) @@ -116,16 +120,16 @@ def _ensure_data_dirs(): def _get_default_runtime_config() -> dict: return { - "active_dataset": None, - "duckdb_paths": { - "demo": str(get_default_database_path("mimic-iv-demo") or ""), - "full": str(get_default_database_path("mimic-iv-full") or ""), - }, - "parquet_roots": { - "demo": str(get_dataset_parquet_root("mimic-iv-demo") or ""), - "full": str(get_dataset_parquet_root("mimic-iv-full") or ""), - }, -} + "active_dataset": None, + "duckdb_paths": { + "demo": str(get_default_database_path("mimic-iv-demo") or ""), + "full": str(get_default_database_path("mimic-iv-full") or ""), + }, + "parquet_roots": { + "demo": str(get_dataset_parquet_root("mimic-iv-demo") or ""), + "full": str(get_dataset_parquet_root("mimic-iv-full") or ""), + }, + } def load_runtime_config() -> dict: @@ -146,16 +150,32 @@ def save_runtime_config(cfg: dict) -> None: def _has_parquet_files(path: Path | None) -> bool: - return bool(path and path.exists() and any(path.rglob("*.parquet"))) + return bool(path and path.exists() and any(path.rglob("*.parquet"))) def detect_available_local_datasets() -> dict: """Return presence flags for demo/full based on Parquet roots and DuckDB files.""" cfg = load_runtime_config() - demo_parquet_path = Path(cfg["parquet_roots"]["demo"]) if cfg["parquet_roots"]["demo"] else get_dataset_parquet_root("mimic-iv-demo") - full_parquet_path = Path(cfg["parquet_roots"]["full"]) if cfg["parquet_roots"]["full"] else get_dataset_parquet_root("mimic-iv-full") - demo_db_path = Path(cfg["duckdb_paths"]["demo"]) if cfg["duckdb_paths"]["demo"] else get_default_database_path("mimic-iv-demo") - full_db_path = Path(cfg["duckdb_paths"]["full"]) if cfg["duckdb_paths"]["full"] else get_default_database_path("mimic-iv-full") + demo_parquet_path = ( + Path(cfg["parquet_roots"]["demo"]) + if cfg["parquet_roots"]["demo"] + else get_dataset_parquet_root("mimic-iv-demo") + ) + full_parquet_path = ( + Path(cfg["parquet_roots"]["full"]) + if cfg["parquet_roots"]["full"] + else get_dataset_parquet_root("mimic-iv-full") + ) + demo_db_path = ( + Path(cfg["duckdb_paths"]["demo"]) + if cfg["duckdb_paths"]["demo"] + else get_default_database_path("mimic-iv-demo") + ) + full_db_path = ( + Path(cfg["duckdb_paths"]["full"]) + if cfg["duckdb_paths"]["full"] + else get_default_database_path("mimic-iv-full") + ) return { "demo": { "parquet_present": _has_parquet_files(demo_parquet_path), diff --git a/src/m3/data_io.py b/src/m3/data_io.py index 853bf9f..f5d7d92 100644 --- a/src/m3/data_io.py +++ b/src/m3/data_io.py @@ -214,6 +214,7 @@ def download_dataset(dataset_name: str, output_root: Path) -> bool: # CSV to Parquet conversion ######################################################## + def _csv_to_parquet_all(src_root: Path, parquet_root: Path) -> bool: """ Convert all CSV files in the source directory to Parquet files. @@ -278,7 +279,9 @@ def _convert_one(csv_gz: Path) -> tuple[Path | None, float]: with ThreadPoolExecutor(max_workers=max_workers) as ex: futures = {ex.submit(_convert_one, f): f for f in csv_files} - logger.info(f"Converting {total_files} CSV files to Parquet using {max_workers} workers...") + logger.info( + f"Converting {total_files} CSV files to Parquet using {max_workers} workers..." + ) for fut in as_completed(futures): try: @@ -290,7 +293,7 @@ def _convert_one(csv_gz: Path) -> tuple[Path | None, float]: elapsed = time.time() - start_time logger.info( f"Progress: {completed}/{total_files} files " - f"({100*completed/total_files:.1f}%) - " + f"({100 * completed / total_files:.1f}%) - " f"Elapsed: {timedelta(seconds=int(elapsed))!s}" ) except Exception as e: @@ -307,7 +310,9 @@ def _convert_one(csv_gz: Path) -> tuple[Path | None, float]: return True -def convert_csv_to_parquet(dataset_name: str, csv_root: Path, parquet_root: Path) -> bool: +def convert_csv_to_parquet( + dataset_name: str, csv_root: Path, parquet_root: Path +) -> bool: """ Public wrapper to convert CSV.gz files to Parquet for a dataset. - csv_root: root folder containing hosp/ and icu/ CSV.gz files @@ -319,10 +324,12 @@ def convert_csv_to_parquet(dataset_name: str, csv_root: Path, parquet_root: Path parquet_root.mkdir(parents=True, exist_ok=True) return _csv_to_parquet_all(csv_root, parquet_root) + ######################################################## # DuckDB functions ######################################################## + def init_duckdb_from_parquet(dataset_name: str, db_target_path: Path) -> bool: """ Initialize or refresh a DuckDB for the dataset by creating views over Parquet. @@ -381,12 +388,11 @@ def _create_duckdb_with_views(db_path: Path, parquet_root: Path) -> bool: # Build view name from directory structure + filename # e.g., hosp/admissions.parquet -> hosp_admissions - parts = list(rel.parent.parts) + [rel.stem] # stem removes .parquet + parts = [*list(rel.parent.parts), rel.stem] # stem removes .parquet # Clean and join parts view_name = "_".join( - p.lower().replace("-", "_").replace(".", "_") - for p in parts if p != "." + p.lower().replace("-", "_").replace(".", "_") for p in parts if p != "." ) # Create view pointing to the specific parquet file @@ -406,7 +412,7 @@ def _create_duckdb_with_views(db_path: Path, parquet_root: Path) -> bool: eta_seconds = avg_time * (len(parquet_files) - idx) logger.info( f"Progress: {idx}/{len(parquet_files)} views " - f"({100*idx/len(parquet_files):.1f}%) - " + f"({100 * idx / len(parquet_files):.1f}%) - " f"Last: {view_name} - " f"ETA: {timedelta(seconds=int(eta_seconds))!s}" ) @@ -438,6 +444,7 @@ def _create_duckdb_with_views(db_path: Path, parquet_root: Path) -> bool: # Verification and utilities ######################################################## + def verify_table_rowcount(db_path: Path, table_name: str) -> int: con = duckdb.connect(str(db_path)) try: @@ -449,7 +456,9 @@ def verify_table_rowcount(db_path: Path, table_name: str) -> int: con.close() -def ensure_duckdb_for_dataset(dataset_key: str) -> tuple[bool, Path | None, Path | None]: +def ensure_duckdb_for_dataset( + dataset_key: str, +) -> tuple[bool, Path | None, Path | None]: """ Ensure DuckDB exists and views are created for the dataset ('mimic-iv-demo'|'mimic-iv-full'). Returns (ok, db_path, parquet_root). diff --git a/src/m3/mcp_client_configs/dynamic_mcp_config.py b/src/m3/mcp_client_configs/dynamic_mcp_config.py index 179d0b3..a879761 100644 --- a/src/m3/mcp_client_configs/dynamic_mcp_config.py +++ b/src/m3/mcp_client_configs/dynamic_mcp_config.py @@ -97,7 +97,7 @@ def generate_config( if not default_path: raise ValueError(_DATABASE_PATH_ERROR_MSG) env["M3_DB_PATH"] = str(default_path) - + elif backend == "bigquery" and project_id: env["M3_PROJECT_ID"] = project_id env["GOOGLE_CLOUD_PROJECT"] = project_id diff --git a/src/m3/mcp_client_configs/setup_claude_desktop.py b/src/m3/mcp_client_configs/setup_claude_desktop.py index 5fcd244..e2ae2b2 100644 --- a/src/m3/mcp_client_configs/setup_claude_desktop.py +++ b/src/m3/mcp_client_configs/setup_claude_desktop.py @@ -163,7 +163,9 @@ def setup_claude_desktop( print(f"🔧 Backend: {backend}") if backend == "duckdb": - db_path_display = db_path or "default (m3_data/databases/mimic_iv_demo.duckdb)" + db_path_display = ( + db_path or "default (m3_data/databases/mimic_iv_demo.duckdb)" + ) print(f"💾 Database: {db_path_display}") elif backend == "bigquery": project_display = project_id or "physionet-data" diff --git a/src/m3/mcp_server.py b/src/m3/mcp_server.py index 97e4fad..1a7ad6f 100644 --- a/src/m3/mcp_server.py +++ b/src/m3/mcp_server.py @@ -195,7 +195,10 @@ def _execute_duckdb_query(sql_query: str) -> str: if df.empty: return "No results found" if len(df) > 50: - out = df.head(50).to_string(index=False) + f"\n... ({len(df)} total rows, showing first 50)" + out = ( + df.head(50).to_string(index=False) + + f"\n... ({len(df)} total rows, showing first 50)" + ) else: out = df.to_string(index=False) return out diff --git a/tests/test_cli.py b/tests/test_cli.py index 32d01f5..8e55966 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,5 @@ -import tempfile import subprocess +import tempfile from pathlib import Path from unittest.mock import MagicMock, patch @@ -148,10 +148,13 @@ def test_config_script_failure(mock_subprocess): # Just verify that the command failed with the right exit code # The specific error message may vary + @patch("subprocess.run") @patch("m3.cli.get_default_database_path") @patch("m3.cli.get_active_dataset") -def test_config_claude_infers_db_path_demo(mock_active, mock_get_default, mock_subprocess): +def test_config_claude_infers_db_path_demo( + mock_active, mock_get_default, mock_subprocess +): mock_active.return_value = None # unset -> default to demo mock_get_default.return_value = Path("/tmp/inferred-demo.duckdb") mock_subprocess.return_value = MagicMock(returncode=0) @@ -171,7 +174,9 @@ def test_config_claude_infers_db_path_demo(mock_active, mock_get_default, mock_s @patch("subprocess.run") @patch("m3.cli.get_default_database_path") @patch("m3.cli.get_active_dataset") -def test_config_claude_infers_db_path_full(mock_active, mock_get_default, mock_subprocess): +def test_config_claude_infers_db_path_full( + mock_active, mock_get_default, mock_subprocess +): mock_active.return_value = "full" mock_get_default.return_value = Path("/tmp/inferred-full.duckdb") mock_subprocess.return_value = MagicMock(returncode=0) @@ -183,6 +188,7 @@ def test_config_claude_infers_db_path_full(mock_active, mock_get_default, mock_s assert "--db-path" in call_args assert "/tmp/inferred-full.duckdb" in call_args + @patch("m3.cli.set_active_dataset") @patch("m3.cli.detect_available_local_datasets") def test_use_full_happy_path(mock_detect, mock_set_active): @@ -229,5 +235,5 @@ def test_status_happy_path(mock_detect, mock_active, mock_size): result = runner.invoke(app, ["status"]) assert result.exit_code == 0 assert "Active dataset: full" in result.stdout - size_gb = 123 / (1024 ** 3) + size_gb = 123 / (1024**3) assert f"parquet_size_gb: {size_gb:.4f} GB" in result.stdout diff --git a/tests/test_data_io.py b/tests/test_data_io.py index 1df838c..6e9352f 100644 --- a/tests/test_data_io.py +++ b/tests/test_data_io.py @@ -1,8 +1,8 @@ -import duckdb import gzip -import requests from unittest import mock -import pytest + +import duckdb +import requests from m3.data_io import ( COMMON_USER_AGENT, diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 9d63689..643a158 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -12,7 +12,8 @@ # Mock the database path check during import to handle CI environments with patch("pathlib.Path.exists", return_value=True): with patch( - "m3.mcp_server.get_default_database_path", return_value=Path("/fake/test.duckdb") + "m3.mcp_server.get_default_database_path", + return_value=Path("/fake/test.duckdb"), ): from m3.mcp_server import _init_backend, mcp From 5ee20e4c40c676e81b0b7745be5846ff28efcbde Mon Sep 17 00:00:00 2001 From: hill Date: Fri, 21 Nov 2025 18:27:32 -0500 Subject: [PATCH 11/13] fix: upgrade pytest stack and sync lockfile --- pyproject.toml | 4 ++-- uv.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5e7c085..fb3d5b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,8 +57,8 @@ dependencies = [ dev = [ "ruff>=0.4.0", "pre-commit>=3.0.0", - "pytest>=7.4.0", - "pytest-asyncio>=0.23.0", + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", "pytest-mock>=3.10.0", "aiohttp>=3.8.0", # For MCP client testing ] diff --git a/uv.lock b/uv.lock index efb8015..60bc079 100644 --- a/uv.lock +++ b/uv.lock @@ -1098,8 +1098,8 @@ requires-dist = [ dev = [ { name = "aiohttp", specifier = ">=3.8.0" }, { name = "pre-commit", specifier = ">=3.0.0" }, - { name = "pytest", specifier = ">=7.4.0" }, - { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-mock", specifier = ">=3.10.0" }, { name = "ruff", specifier = ">=0.4.0" }, ] From cbaa89a8df7831d013a37e01bf91734b812c88a9 Mon Sep 17 00:00:00 2001 From: hill Date: Fri, 21 Nov 2025 22:08:19 -0500 Subject: [PATCH 12/13] fix: modernize CI pytest version --- .github/workflows/pre-commit.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 7e7005e..8d796c4 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -25,7 +25,6 @@ jobs: ln -s $(which uv) ~/.local/bin/uv - run: uv venv - run: uv sync --dev - - run: uv add pytest==7.4.3 - uses: tox-dev/action-pre-commit-uv@v1 with: extra_args: --all-files From 0dc5b373a49e2e9a84aed0bde7310ea3deea7b19 Mon Sep 17 00:00:00 2001 From: hill Date: Sat, 22 Nov 2025 09:03:58 -0500 Subject: [PATCH 13/13] fix: modernize CI pytest versions in test and publish workflows --- .github/workflows/publish.yaml | 15 +++++---------- .github/workflows/tests.yaml | 1 - 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 04d4855..cc2f17c 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -33,23 +33,18 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Publishing version: $VERSION" - - name: Update version in pyproject.toml + - name: Update version in __init__.py run: | - # Update version in pyproject.toml to match the git tag - sed -i "s/version = \".*\"/version = \"${{ steps.get_version.outputs.version }}\"/" pyproject.toml - echo "Updated pyproject.toml version to ${{ steps.get_version.outputs.version }}" - cat pyproject.toml | grep version - - - name: Lock dependencies - run: uv lock --locked + # Update version in src/m3/__init__.py to match the git tag + sed -i "s/__version__ = \".*\"/__version__ = \"${{ steps.get_version.outputs.version }}\"/" src/m3/__init__.py + echo "Updated src/m3/__init__.py version to ${{ steps.get_version.outputs.version }}" + grep "__version__" src/m3/__init__.py - name: Sync dependencies including dev run: uv sync --all-groups - name: Run quick tests run: | - uv add pytest==7.4.3 - uv add pytest-asyncio uv run pytest tests/ -v --tb=short - name: Build package diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 44d21d1..55c541d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -27,7 +27,6 @@ jobs: - name: Install dependencies run: | uv sync --all-groups - uv add pytest==7.4.3 - name: Run tests run: uv run pytest -v