From abc6390174c5df2b6451d6f0b0e2df8067f9ff31 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 21 Apr 2026 21:55:18 +0200 Subject: [PATCH 001/151] =?UTF-8?q?Am=C3=A9liorations=20CLAUDE.md=20pour?= =?UTF-8?q?=20plus=20utiliser=20rtk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 509783b..5af8a09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,27 +10,38 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Setup +Setting up the virtual environment: + ```bash -cp template.env .env # then customize .env +python -m venv .venv # s'il n'existe pas déjà +source .venv/bin/activate +rtk pip install -U pip > /dev/null 2>&1 +rtk pip install -e . --group=dev +``` + +Environment variables: + +```bash +cp .template.env .env # then customize .env ``` ### Development ```bash -uv run run.py # starts Dash with debug=True and hot reload +python run.py # starts Dash app ``` ### Production ```bash -uv run gunicorn app:server +gunicorn app:server ``` ### Tests ```bash -rtk uv run pytest # run all tests (Selenium-based integration tests) -rtk uv run pytest tests/test_main.py::test_001_logo_and_search # run a single test +rtk pytest # run all tests (some are Selenium-based integration tests) +rtk pytest tests/test_main.py::test_001_logo_and_search # run a single test ``` Tests require a running Chrome/Chromium browser. They use `DashComposite` from `dash[testing]` with Selenium WebDriver. @@ -40,12 +51,12 @@ Tests require a running Chrome/Chromium browser. They use `DashComposite` from ` ### Multi-page Dash app - `src/app.py` — creates the Dash app instance, navbar, SEO endpoints (robots.txt, sitemap.xml), Matomo analytics -- `src/pages/*.py` — each page registers itself with `@register_page()` and owns its own layout and callbacks +- `src/pages/*.py` — each page registers itself with `@register_page()` and o.wns its own layout and callbacks - `run.py` — dev entry point; exports `server` (Flask) for gunicorn ### Module imports -- always import modules from the app starting with `src.` (e.g. `src.utils.`, `src.pages.recherche`, etc.) +- always import modules from the app starting with `src.` (e.g. `src.utils.`, `src.pages.recherche`, etc.), NOT `utils.cache` or `pages.observatoire`. ### Key pages @@ -60,9 +71,9 @@ Tests require a running Chrome/Chromium browser. They use `DashComposite` from ` ### Data layer -- Data is stored as **Parquet** and loaded with **Polars** (fast columnar operations) +- Data is stored as **Parquet** at rest, possibly in DuckDB, loaded in DuckDB, served from DuckDB for big queries and manipulated with **Polars** for the remaining steps - Path set via `DATA_FILE_PARQUET_PATH` env var; tests use `tests/test.parquet` -- `src/utils.py` — filtering helpers, search (`search_org`), link generation, geographic data loading +- `src/util/*.py` — helpers shared by other modules, search (`search_org`), link generation, geographic data loading - `src/callbacks.py` — shared Dash callbacks (e.g. `get_top_org_table`) - `src/figures.py` — chart and map components (Plotly Express, Dash Leaflet with marker clustering) - a Parquet file with production data is located at `../decp-processing/decp_prod.parquet` (~ 1,5 million records) From 95c90e319e70557314c3415ea816b885365f1d1d Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 21 Apr 2026 23:33:49 +0200 Subject: [PATCH 002/151] =?UTF-8?q?feat:=20ajouter=20traducteurs=20filter?= =?UTF-8?q?=5Fquery=E2=86=92SQL=20et=20sort=5Fby=E2=86=92SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/utils/table_sql.py | 103 +++++++++++++++++++++++++++ tests/test_table_sql.py | 150 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 src/utils/table_sql.py create mode 100644 tests/test_table_sql.py diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py new file mode 100644 index 0000000..bf00af0 --- /dev/null +++ b/src/utils/table_sql.py @@ -0,0 +1,103 @@ +import polars as pl + +from src.utils import logger +from src.utils.table import split_filter_part + + +def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list]: + """Traduit le DSL de filtres de dash_table.DataTable en fragment SQL DuckDB. + + Retourne (where_clause, params) où where_clause est un fragment à injecter + après WHERE et params est la liste des valeurs à passer à + cursor.execute(sql, params). Les identifiants de colonnes sont validés + contre le schéma fourni ; jamais concaténés avec des valeurs utilisateur. + """ + if not filter_query: + return "TRUE", [] + + clauses: list[str] = [] + params: list = [] + + for part in filter_query.split(" && "): + col_name, operator, raw_value = split_filter_part(part) + if not isinstance(col_name, str) or not isinstance(raw_value, str): + continue + + if col_name not in schema.names(): + logger.warning(f"Colonne inconnue ignorée : {col_name!r}") + continue + + col_type = str(schema[col_name]) + is_numeric = col_type.startswith("Int") or col_type.startswith("Float") + quoted_col = f'"{col_name}"' + + if is_numeric: + try: + value = ( + int(raw_value) if col_type.startswith("Int") else float(raw_value) + ) + except ValueError: + logger.warning(f"Valeur numérique invalide ignorée : {raw_value!r}") + continue + + if operator == "contains": + clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} = ?") + elif operator == ">": + clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} > ?") + elif operator == "<": + clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} < ?") + else: + logger.warning(f"Opérateur invalide pour numérique : {operator!r}") + continue + params.append(value) + continue + + # String / Date : toujours traité comme texte (parité avec Polars) + value = raw_value.strip('"') + col_is_date = col_type == "Date" + + if operator == "contains": + if value.endswith("*") and not value.startswith("*"): + like = value[:-1] + "%" + elif value.startswith("*") and not value.endswith("*"): + like = "%" + value[1:] + else: + like = "%" + value + "%" + target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col + clauses.append( + f"{quoted_col} IS NOT NULL AND {target} <> '' AND {target} ILIKE ?" + ) + params.append(like) + elif operator in (">", "<"): + target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col + clauses.append(f"{quoted_col} IS NOT NULL AND {target} {operator} ?") + params.append(value) + else: + logger.warning(f"Opérateur invalide pour chaîne : {operator!r}") + continue + + if not clauses: + return "TRUE", [] + return " AND ".join(clauses), params + + +def sort_by_to_sql(sort_by: list[dict] | None, schema: pl.Schema) -> str: + """Traduit sort_by (format Dash) en clause ORDER BY DuckDB. + + Retourne '' si pas de tri (aucun ORDER BY à ajouter). + """ + if not sort_by: + return "" + + fragments: list[str] = [] + for entry in sort_by: + col = entry.get("column_id") + direction = entry.get("direction") + if col not in schema.names(): + logger.warning(f"Tri sur colonne inconnue ignoré : {col!r}") + continue + if direction not in ("asc", "desc"): + continue + fragments.append(f'"{col}" {direction.upper()} NULLS LAST') + + return ", ".join(fragments) diff --git a/tests/test_table_sql.py b/tests/test_table_sql.py new file mode 100644 index 0000000..8675e19 --- /dev/null +++ b/tests/test_table_sql.py @@ -0,0 +1,150 @@ +import polars as pl + +SCHEMA = pl.Schema( + { + "uid": pl.String, + "objet": pl.String, + "acheteur_id": pl.String, + "montant": pl.Float64, + "dureeMois": pl.Int64, + "dateNotification": pl.Date, + } +) + + +def test_empty_filter_returns_true(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("", SCHEMA) + assert where == "TRUE" + assert params == [] + + +def test_icontains_string_is_case_insensitive_like(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{objet} icontains travaux", SCHEMA) + assert where == '"objet" IS NOT NULL AND "objet" <> \'\' AND "objet" ILIKE ?' + assert params == ["%travaux%"] + + +def test_icontains_with_trailing_wildcard_is_starts_with(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql( + "{acheteur_id} icontains 24350013900189*", SCHEMA + ) + assert ( + where + == '"acheteur_id" IS NOT NULL AND "acheteur_id" <> \'\' AND "acheteur_id" ILIKE ?' + ) + assert params == ["24350013900189%"] + + +def test_icontains_with_leading_wildcard_is_ends_with(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{uid} icontains *2024", SCHEMA) + assert where == '"uid" IS NOT NULL AND "uid" <> \'\' AND "uid" ILIKE ?' + assert params == ["%2024"] + + +def test_numeric_greater_than(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{montant} i> 40000", SCHEMA) + assert where == '"montant" IS NOT NULL AND "montant" > ?' + assert params == [40000.0] + + +def test_numeric_less_than(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{montant} i< 1000", SCHEMA) + assert where == '"montant" IS NOT NULL AND "montant" < ?' + assert params == [1000.0] + + +def test_numeric_equality_via_icontains(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{dureeMois} icontains 12", SCHEMA) + assert where == '"dureeMois" IS NOT NULL AND "dureeMois" = ?' + assert params == [12] + + +def test_date_column_treated_as_string_ilike(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{dateNotification} icontains 2024*", SCHEMA) + assert "ILIKE" in where + assert params == ["2024%"] + + +def test_multiple_filters_joined_by_and(): + from src.utils.table_sql import filter_query_to_sql + + filter_query = "{objet} icontains voirie && {montant} i> 40000" + where, params = filter_query_to_sql(filter_query, SCHEMA) + assert " AND " in where + assert params == ["%voirie%", 40000.0] + + +def test_invalid_numeric_value_is_skipped(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{montant} i> notanumber", SCHEMA) + assert where == "TRUE" + assert params == [] + + +def test_unknown_column_is_skipped(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{inexistant} icontains foo", SCHEMA) + assert where == "TRUE" + assert params == [] + + +def test_escapes_identifier_with_quotes_not_concatenation(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql( + "{objet} icontains '; DROP TABLE decp; --", SCHEMA + ) + assert "DROP TABLE" not in where + assert any("DROP TABLE" in str(p) for p in params) + + +def test_sort_by_empty(): + from src.utils.table_sql import sort_by_to_sql + + assert sort_by_to_sql([], SCHEMA) == "" + assert sort_by_to_sql(None, SCHEMA) == "" + + +def test_sort_by_single_column_desc(): + from src.utils.table_sql import sort_by_to_sql + + result = sort_by_to_sql([{"column_id": "montant", "direction": "desc"}], SCHEMA) + assert result == '"montant" DESC NULLS LAST' + + +def test_sort_by_multiple_columns_preserves_order(): + from src.utils.table_sql import sort_by_to_sql + + result = sort_by_to_sql( + [ + {"column_id": "dateNotification", "direction": "desc"}, + {"column_id": "montant", "direction": "asc"}, + ], + SCHEMA, + ) + assert result == '"dateNotification" DESC NULLS LAST, "montant" ASC NULLS LAST' + + +def test_sort_by_ignores_unknown_column(): + from src.utils.table_sql import sort_by_to_sql + + result = sort_by_to_sql([{"column_id": "fake", "direction": "asc"}], SCHEMA) + assert result == "" From 74213d3844d8296fcbb82227dcb819dacc1ec2ce Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 21 Apr 2026 23:37:03 +0200 Subject: [PATCH 003/151] =?UTF-8?q?fix(table=5Fsql):=20g=C3=A9rer=20*foo*?= =?UTF-8?q?=20et=20utiliser=20isinstance=20pour=20les=20types=20Polars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/utils/table_sql.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index bf00af0..f48ea35 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -27,14 +27,19 @@ def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list logger.warning(f"Colonne inconnue ignorée : {col_name!r}") continue - col_type = str(schema[col_name]) - is_numeric = col_type.startswith("Int") or col_type.startswith("Float") + col_type = schema[col_name] + is_numeric = isinstance( + col_type, (pl.Int8, pl.Int16, pl.Int32, pl.Int64, pl.Float32, pl.Float64) + ) + col_is_date = col_type == pl.Date quoted_col = f'"{col_name}"' if is_numeric: try: value = ( - int(raw_value) if col_type.startswith("Int") else float(raw_value) + int(raw_value) + if isinstance(col_type, (pl.Int8, pl.Int16, pl.Int32, pl.Int64)) + else float(raw_value) ) except ValueError: logger.warning(f"Valeur numérique invalide ignorée : {raw_value!r}") @@ -54,13 +59,14 @@ def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list # String / Date : toujours traité comme texte (parité avec Polars) value = raw_value.strip('"') - col_is_date = col_type == "Date" if operator == "contains": if value.endswith("*") and not value.startswith("*"): like = value[:-1] + "%" elif value.startswith("*") and not value.endswith("*"): like = "%" + value[1:] + elif value.startswith("*") and value.endswith("*"): + like = "%" + value[1:-1] + "%" else: like = "%" + value + "%" target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col From cdb6a70f7a3261d8ba1a6ca90d0ae52a2d330c16 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 21 Apr 2026 23:39:51 +0200 Subject: [PATCH 004/151] =?UTF-8?q?feat(db):=20ajouter=20count=5Fmarches,?= =?UTF-8?q?=20count=5Funique=5Fmarches=20et=20param=C3=A8tre=20offset=20?= =?UTF-8?q?=C3=A0=20query=5Fmarches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/db.py | 19 ++++++++++++++++++- tests/test_db.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/db.py b/src/db.py index 9d96872..9290e48 100644 --- a/src/db.py +++ b/src/db.py @@ -135,10 +135,11 @@ def get_cursor() -> duckdb.DuckDBPyConnection: def query_marches( where_sql: str = "TRUE", - params: tuple = (), + params: tuple | list = (), columns: list[str] | None = None, order_by: str | None = None, limit: int | None = None, + offset: int | None = None, ) -> pl.DataFrame: """Run a parameterized SELECT against the decp table and return Polars. @@ -152,4 +153,20 @@ def query_marches( sql += f" ORDER BY {order_by}" if limit is not None: sql += f" LIMIT {int(limit)}" + if offset is not None: + sql += f" OFFSET {int(offset)}" return get_cursor().execute(sql, list(params)).pl() + + +def count_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int: + """Retourne le nombre de lignes correspondant à where_sql.""" + sql = f"SELECT COUNT(*) FROM decp WHERE {where_sql}" + result = get_cursor().execute(sql, list(params)).fetchone() + return int(result[0]) if result else 0 + + +def count_unique_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int: + """Retourne le nombre de uid distincts correspondant à where_sql.""" + sql = f"SELECT COUNT(DISTINCT uid) FROM decp WHERE {where_sql}" + result = get_cursor().execute(sql, list(params)).fetchone() + return int(result[0]) if result else 0 diff --git a/tests/test_db.py b/tests/test_db.py index 2edd1c5..0ca9e36 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -206,6 +206,38 @@ def test_query_marches_returns_polars_frame(built_db, monkeypatch): assert set(frame["uid"].to_list()) == {"1", "2"} +def test_count_marches_returns_total_without_filter(): + from src.db import count_marches + + n = count_marches() + assert isinstance(n, int) + assert n > 0 + + +def test_count_marches_with_filter(): + from src.db import count_marches + + n = count_marches('"uid" = ?', ["__nonexistent__"]) + assert n == 0 + + +def test_count_unique_marches_respects_distinct(): + from src.db import count_unique_marches + + n = count_unique_marches() + assert isinstance(n, int) + assert n > 0 + + +def test_query_marches_with_offset(): + from src.db import query_marches + + page_0 = query_marches(limit=2, offset=0) + page_1 = query_marches(limit=2, offset=2) + if page_0.height == 2 and page_1.height >= 1: + assert set(page_0["uid"].to_list()).isdisjoint(set(page_1["uid"].to_list())) + + def test_concurrent_build_serialized(tmp_path): """Multiple threads calling _ensure_database must serialize via flock. From 4e25ff5c85fa6b4c5679a998bd8c43fe0362ebff Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 21 Apr 2026 23:42:52 +0200 Subject: [PATCH 005/151] feat(table): ajouter postprocess_page pour post-traiter une page seule Co-Authored-By: Claude Sonnet 4.6 --- src/utils/table.py | 16 ++++++++++++++++ tests/test_table.py | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/utils/table.py b/src/utils/table.py index 096acf5..2c0d2ea 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -410,6 +410,22 @@ def table_postprocess(lff) -> pl.DataFrame: return dff +def postprocess_page(dff: pl.DataFrame) -> pl.DataFrame: + """Post-traitement à appliquer sur une page déjà paginée. + + Équivalent à table_postprocess mais prend une DataFrame (pas un LazyFrame) + et ne matérialise rien de plus. À appeler après que la pagination ait été + poussée en SQL. + """ + dff = dff.with_columns(pl.all().cast(pl.String).fill_null("")) + dff = add_links(dff) + if "sourceFile" in dff.columns: + dff = add_resource_link(dff) + if dff.height > 0: + dff = format_values(dff) + return dff + + def prepare_table_data( data, data_timestamp, filter_query, page_current, page_size, sort_by, source_table ): diff --git a/tests/test_table.py b/tests/test_table.py index cc89386..c47f967 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -303,3 +303,19 @@ def test_prepare_table_data_with_external_data_does_not_use_cache( ) assert sentinel["called"] is False + + +def test_postprocess_page_produces_same_result_as_full_then_slice( + flask_app, sample_lff +): + """Post-traiter 20 lignes doit donner le même résultat que post-traiter + l'ensemble puis slicer.""" + from src.utils import table + + full = sample_lff.collect() + with flask_app.app_context(): + via_full = table.table_postprocess(full.lazy()).slice(0, 1) + via_page = table.postprocess_page(full.slice(0, 1)) + assert via_full.columns == via_page.columns + for col in via_full.columns: + assert via_full[col].to_list() == via_page[col].to_list() From 1e67d329d03fa65cc1f032f081e0ac929c79b0bb Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 21 Apr 2026 23:53:45 +0200 Subject: [PATCH 006/151] perf(tableau): pousser filtre/tri/pagination/comptage dans DuckDB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace le chemin lent de prepare_table_data (chargement de toutes les lignes depuis DuckDB puis filtrage/post-traitement Polars avant slice) par _fetch_page_sql qui pousse filtre, tri, pagination et comptage dans DuckDB via filter_query_to_sql / sort_by_to_sql, puis post-traite uniquement la page de 20 lignes. Supprime _load_filter_sort_postprocess (plus utilisé). Met à jour les tests test_table.py en supprimant les tests associés et en ajoutant des tests dédiés pour _fetch_page_sql. Corrige le fixture flask_app pour utiliser src.utils.cache (même instance que le module) afin que @cache.memoize() fonctionne. Co-Authored-By: Claude Sonnet 4.6 --- src/utils/table.py | 88 +++++++++++++++++--------- tests/test_table.py | 151 ++++++++++++++++++++------------------------ 2 files changed, 124 insertions(+), 115 deletions(-) diff --git a/src/utils/table.py b/src/utils/table.py index 2c0d2ea..9e47142 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -5,7 +5,7 @@ import polars as pl from dash import no_update from polars import selectors as cs -from src.db import query_marches, schema +from src.db import count_marches, count_unique_marches, query_marches, schema from src.utils import logger from src.utils.cache import cache from src.utils.data import DATA_SCHEMA @@ -376,28 +376,6 @@ def get_default_hidden_columns(page): return hidden_columns -@cache.memoize() -def _load_filter_sort_postprocess(filter_query, sort_by_key): - logger.debug( - f"Cache miss — recomputing for filter={filter_query!r} sort={sort_by_key!r}" - ) - - lff: pl.LazyFrame = query_marches().lazy() - - if filter_query: - lff = filter_table_data(lff, filter_query) - - if sort_by_key: - sort_by = [ - {"column_id": col, "direction": direction} for col, direction in sort_by_key - ] - lff = sort_table_data(lff, sort_by) - - dff = table_postprocess(lff) - - return dff - - def table_postprocess(lff) -> pl.DataFrame: lff = lff.cast(pl.String) lff = lff.fill_null("") @@ -426,6 +404,48 @@ def postprocess_page(dff: pl.DataFrame) -> pl.DataFrame: return dff +@cache.memoize() +def _fetch_page_sql( + filter_query: str | None, + sort_by_key: tuple, + page_current: int, + page_size: int, +) -> tuple[pl.DataFrame, int, int]: + """Chemin rapide : filtre/tri/pagine dans DuckDB, post-traite la page seule. + + Retourne (page_dataframe_post_traitée, total_count, total_unique_count). + """ + # Import local pour éviter une dépendance circulaire + # (src.utils.table_sql importe split_filter_part depuis src.utils.table). + from src.utils.table_sql import filter_query_to_sql, sort_by_to_sql + + logger.debug( + f"Cache miss SQL — filter={filter_query!r} sort={sort_by_key!r} " + f"page={page_current} size={page_size}" + ) + + where_sql, params = filter_query_to_sql(filter_query or "", schema) + + sort_by_dash = [ + {"column_id": col, "direction": direction} for col, direction in sort_by_key + ] + order_by = sort_by_to_sql(sort_by_dash, schema) or None + + total = count_marches(where_sql, params) + total_unique = count_unique_marches(where_sql, params) + + page = query_marches( + where_sql=where_sql, + params=params, + order_by=order_by, + limit=page_size, + offset=page_current * page_size, + ) + + page = postprocess_page(page) + return page, total, total_unique + + def prepare_table_data( data, data_timestamp, filter_query, page_current, page_size, sort_by, source_table ): @@ -450,10 +470,16 @@ def prepare_table_data( if data is None: sort_by_key = normalize_sort_by(sort_by) - dff: pl.DataFrame = _load_filter_sort_postprocess( - filter_query=filter_query, sort_by_key=sort_by_key + dff, total, total_unique = _fetch_page_sql( + filter_query=filter_query, + sort_by_key=sort_by_key, + page_current=page_current, + page_size=page_size, ) + height = total + already_paginated = True else: + already_paginated = False if isinstance(data, list): lff: pl.LazyFrame = pl.LazyFrame( data, strict=False, infer_schema_length=5000 @@ -470,19 +496,19 @@ def prepare_table_data( lff = sort_table_data(lff, sort_by) dff: pl.DataFrame = table_postprocess(lff) - - height = dff.height + height = dff.height + total_unique = dff.select("uid").unique().height if "uid" in dff.columns else 0 if height > 0: nb_rows = ( - f"{format_number(height)} lignes " - f"({format_number(dff.select('uid').unique().height)} marchés)" + f"{format_number(height)} lignes ({format_number(total_unique)} marchés)" ) else: nb_rows = "0 lignes (0 marchés)" - start_row = page_current * page_size - dff = dff.slice(start_row, page_size) + if not already_paginated: + start_row = page_current * page_size + dff = dff.slice(start_row, page_size) table_columns, tooltip = setup_table_columns(dff) diff --git a/tests/test_table.py b/tests/test_table.py index c47f967..65a9f52 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -83,7 +83,7 @@ def flask_app(): """Minimal Flask app with SimpleCache so @cache.memoize() works in tests.""" from flask import Flask - from utils.cache import cache + from src.utils.cache import cache app = Flask(__name__) cache.init_app(app, config={"CACHE_TYPE": "SimpleCache"}) @@ -95,7 +95,7 @@ def reset_cache(flask_app): """Ensure the flask-caching backend is empty between tests so that cache-hit assertions are meaningful. Falls back to no-op when no Flask app context is active (NullCache).""" - from utils.cache import cache + from src.utils.cache import cache with flask_app.app_context(): try: @@ -106,60 +106,9 @@ def reset_cache(flask_app): yield -def test_load_filter_sort_postprocess_returns_dataframe( - flask_app, monkeypatch, sample_lff -): +def test_prepare_table_data_returns_expected_tuple(flask_app): from src.utils import table - monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect()) - - with flask_app.app_context(): - df = table._load_filter_sort_postprocess(filter_query=None, sort_by_key=()) - - assert isinstance(df, pl.DataFrame) - assert df.height == 1 - # All values must be strings after post-processing - for col in df.columns: - assert df.schema[col] == pl.String - - -def test_load_filter_sort_postprocess_applies_filter( - flask_app, monkeypatch, sample_lff -): - from src.utils import table - - monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect()) - - with flask_app.app_context(): - df = table._load_filter_sort_postprocess( - filter_query="{objet} icontains travaux", sort_by_key=() - ) - assert df.height == 1 - - df_empty = table._load_filter_sort_postprocess( - filter_query="{objet} icontains nonexistent", sort_by_key=() - ) - assert df_empty.height == 0 - - -def test_load_filter_sort_postprocess_adds_links(flask_app, monkeypatch, sample_lff): - from src.utils import table - - monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect()) - - with flask_app.app_context(): - df = table._load_filter_sort_postprocess(filter_query=None, sort_by_key=()) - # add_links injects an wrapper around uid, acheteur_nom, titulaire_nom - assert "= 1 -def test_prepare_table_data_cleanup_trigger_for_non_tableau( - monkeypatch, flask_app, sample_lff -): +def test_prepare_table_data_cleanup_trigger_for_non_tableau(flask_app): """Non-tableau pages still get a fresh uuid trigger, not no_update.""" from dash import no_update from src.utils import table - monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect()) - with flask_app.app_context(): result = table.prepare_table_data( data=None, @@ -289,11 +234,11 @@ def test_prepare_table_data_with_external_data_does_not_use_cache( sentinel["called"] = True raise AssertionError("Memoized helper must not be called when data is provided") - monkeypatch.setattr(table, "_load_filter_sort_postprocess", should_not_be_called) + monkeypatch.setattr(table, "_fetch_page_sql", should_not_be_called) with flask_app.app_context(): table.prepare_table_data( - data=sample_lff, # external LazyFrame + data=sample_lff, data_timestamp=0, filter_query=None, page_current=0, @@ -305,6 +250,44 @@ def test_prepare_table_data_with_external_data_does_not_use_cache( assert sentinel["called"] is False +def test_fetch_page_sql_respects_pagination(flask_app): + """New path: returns (page_dff, total_count, total_unique) via DuckDB.""" + from src.utils import table + + with flask_app.app_context(): + page, total, total_unique = table._fetch_page_sql( + filter_query=None, sort_by_key=(), page_current=0, page_size=5 + ) + assert page.height <= 5 + assert total >= page.height + assert isinstance(total_unique, int) + + +def test_fetch_page_sql_applies_filter(flask_app): + from src.utils import table + + with flask_app.app_context(): + page, total, total_unique = table._fetch_page_sql( + filter_query="{uid} icontains __ne_matche_rien__", + sort_by_key=(), + page_current=0, + page_size=20, + ) + assert total == 0 + assert page.height == 0 + + +def test_fetch_page_sql_post_processes_links(flask_app): + from src.utils import table + + with flask_app.app_context(): + page, _, _ = table._fetch_page_sql( + filter_query=None, sort_by_key=(), page_current=0, page_size=1 + ) + if page.height > 0: + assert " Date: Wed, 22 Apr 2026 17:17:54 +0200 Subject: [PATCH 007/151] Simplifications du code #72 --- src/utils/table.py | 3 +-- src/utils/table_sql.py | 13 +++---------- uv.lock | 2 +- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/utils/table.py b/src/utils/table.py index 9e47142..e3b59a8 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -470,13 +470,12 @@ def prepare_table_data( if data is None: sort_by_key = normalize_sort_by(sort_by) - dff, total, total_unique = _fetch_page_sql( + dff, height, total_unique = _fetch_page_sql( filter_query=filter_query, sort_by_key=sort_by_key, page_current=page_current, page_size=page_size, ) - height = total already_paginated = True else: already_paginated = False diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index f48ea35..67d7dcc 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -28,19 +28,13 @@ def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list continue col_type = schema[col_name] - is_numeric = isinstance( - col_type, (pl.Int8, pl.Int16, pl.Int32, pl.Int64, pl.Float32, pl.Float64) - ) + is_numeric = col_type.is_numeric() col_is_date = col_type == pl.Date quoted_col = f'"{col_name}"' if is_numeric: try: - value = ( - int(raw_value) - if isinstance(col_type, (pl.Int8, pl.Int16, pl.Int32, pl.Int64)) - else float(raw_value) - ) + value = int(raw_value) if col_type.is_integer() else float(raw_value) except ValueError: logger.warning(f"Valeur numérique invalide ignorée : {raw_value!r}") continue @@ -65,8 +59,6 @@ def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list like = value[:-1] + "%" elif value.startswith("*") and not value.endswith("*"): like = "%" + value[1:] - elif value.startswith("*") and value.endswith("*"): - like = "%" + value[1:-1] + "%" else: like = "%" + value + "%" target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col @@ -103,6 +95,7 @@ def sort_by_to_sql(sort_by: list[dict] | None, schema: pl.Schema) -> str: logger.warning(f"Tri sur colonne inconnue ignoré : {col!r}") continue if direction not in ("asc", "desc"): + logger.warning(f"Tri sur direction inconnue ignoré : {direction!r}") continue fragments.append(f'"{col}" {direction.upper()} NULLS LAST') diff --git a/uv.lock b/uv.lock index 2c14012..0fd8ca1 100644 --- a/uv.lock +++ b/uv.lock @@ -760,7 +760,7 @@ wheels = [ [[package]] name = "decp-info" -version = "2.7.2" +version = "2.7.3" source = { virtual = "." } dependencies = [ { name = "dash", extra = ["compress"] }, From b4956c34d16717fc4a1950f9f1f7828865713341 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 18:30:36 +0200 Subject: [PATCH 008/151] Utilisation d'une seule fonction postprocess #72 --- src/utils/table.py | 34 ++++++++++------------------------ tests/test_table.py | 16 ---------------- 2 files changed, 10 insertions(+), 40 deletions(-) diff --git a/src/utils/table.py b/src/utils/table.py index e3b59a8..cb4108e 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -376,24 +376,10 @@ def get_default_hidden_columns(page): return hidden_columns -def table_postprocess(lff) -> pl.DataFrame: - lff = lff.cast(pl.String) - lff = lff.fill_null("") - dff: pl.DataFrame = lff.collect() - dff = add_links(dff) - if "sourceFile" in dff.columns: - dff = add_resource_link(dff) - if dff.height > 0: - dff = format_values(dff) - return dff - - def postprocess_page(dff: pl.DataFrame) -> pl.DataFrame: """Post-traitement à appliquer sur une page déjà paginée. - Équivalent à table_postprocess mais prend une DataFrame (pas un LazyFrame) - et ne matérialise rien de plus. À appeler après que la pagination ait été - poussée en SQL. + À appeler après la pagination. """ dff = dff.with_columns(pl.all().cast(pl.String).fill_null("")) dff = add_links(dff) @@ -469,6 +455,7 @@ def prepare_table_data( trigger_cleanup = no_update if source_table == "tableau" else str(uuid.uuid4()) if data is None: + # Probablement car il s'agit de la page Tableau sort_by_key = normalize_sort_by(sort_by) dff, height, total_unique = _fetch_page_sql( filter_query=filter_query, @@ -476,9 +463,7 @@ def prepare_table_data( page_current=page_current, page_size=page_size, ) - already_paginated = True else: - already_paginated = False if isinstance(data, list): lff: pl.LazyFrame = pl.LazyFrame( data, strict=False, infer_schema_length=5000 @@ -491,12 +476,17 @@ def prepare_table_data( if filter_query: lff = filter_table_data(lff, filter_query) + df_height = lff.select("uid").collect(engine="streaming") + height = df_height.height + total_unique = df_height["uid"].n_unique() + if sort_by and len(sort_by) > 0: lff = sort_table_data(lff, sort_by) - dff: pl.DataFrame = table_postprocess(lff) - height = dff.height - total_unique = dff.select("uid").unique().height if "uid" in dff.columns else 0 + start_row = page_current * page_size + lff = lff.slice(start_row, page_size) + dff = lff.collect(engine="streaming") + dff: pl.DataFrame = postprocess_page(dff) if height > 0: nb_rows = ( @@ -505,10 +495,6 @@ def prepare_table_data( else: nb_rows = "0 lignes (0 marchés)" - if not already_paginated: - start_row = page_current * page_size - dff = dff.slice(start_row, page_size) - table_columns, tooltip = setup_table_columns(dff) dicts = dff.to_dicts() diff --git a/tests/test_table.py b/tests/test_table.py index 65a9f52..643e34c 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -286,19 +286,3 @@ def test_fetch_page_sql_post_processes_links(flask_app): ) if page.height > 0: assert " Date: Wed, 22 Apr 2026 20:43:38 +0200 Subject: [PATCH 009/151] Path de duckdb configurable, correction des tests #72 --- .template.env | 1 + pyproject.toml | 1 + src/db.py | 2 +- tests/conftest.py | 99 ++++++++++++++++++++++++++--------------------- tests/test_db.py | 1 + 5 files changed, 59 insertions(+), 45 deletions(-) diff --git a/.template.env b/.template.env index 67df0ba..6ac9a91 100644 --- a/.template.env +++ b/.template.env @@ -1,4 +1,5 @@ DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432 +DUCKDB_PATH=./decp.duckdb PORT=8050 DEVELOPMENT=True SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234" diff --git a/pyproject.toml b/pyproject.toml index 4c8748c..212d0bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ testpaths = ["tests"] env = [ "DATA_FILE_PARQUET_PATH=tests/test.parquet", "DEVELOPMENT=true", + "REBUILD_DUCKDB=true", "DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json", ] addopts = "-p no:warnings" diff --git a/src/db.py b/src/db.py index 9290e48..f9ffd39 100644 --- a/src/db.py +++ b/src/db.py @@ -110,7 +110,7 @@ def build_database(db_path: Path, parquet_path: Path) -> None: def _ensure_database() -> Path: - db_path = Path("./decp.duckdb") + db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb")) parquet_path = Path(os.getenv("DATA_FILE_PARQUET_PATH")) lock_path = db_path.with_suffix(".duckdb.lock") diff --git a/tests/conftest.py b/tests/conftest.py index df103aa..f8d702f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,55 +6,66 @@ import polars as pl import pytest from selenium.webdriver.chrome.options import Options +_TEST_DATA = [ + { + "uid": "1", + "id": "1", + "acheteur_nom": "ACHETEUR 1", + "acheteur_id": "123", + "titulaire_nom": "TITULAIRE 1", + "titulaire_id": "345", + "montant": 10, + "dateNotification": datetime.date(2025, 1, 1), + "codeCPV": "71600000", + "donneesActuelles": True, + "acheteur_departement_code": "75", + "acheteur_departement_nom": "Paris", + "acheteur_commune_nom": "Paris", + "titulaire_departement_code": "35", + "titulaire_departement_nom": "Ille-et-Vilaine", + "titulaire_commune_nom": "Rennes", + "titulaire_distance": 10, + "titulaire_typeIdentifiant": "SIRET", + "objet": "Objet test", + "dureeRestanteMois": 12, + "lieuExecution_code": "75001", + "sourceFile": "test.xml", + "sourceDataset": "test_dataset", + "datePublicationDonnees": datetime.date(2025, 1, 1), + "considerationsSociales": "", + "considerationsEnvironnementales": "", + "type": "Marché", + "acheteur_categorie": "Collectivité", + "titulaire_categorie": "PME", + } +] +_PARQUET_PATH = Path(os.path.abspath("tests/test.parquet")) +_DB_PATH = Path(os.path.abspath("decp.duckdb")) -@pytest.fixture(scope="session", autouse=True) -def test_data(): - data = [ - { - "uid": "1", - "id": "1", - "acheteur_nom": "ACHETEUR 1", - "acheteur_id": "123", - "titulaire_nom": "TITULAIRE 1", - "titulaire_id": "345", - "montant": 10, - "dateNotification": datetime.date(2025, 1, 1), - "codeCPV": "71600000", - "donneesActuelles": True, - "acheteur_departement_code": "75", - "acheteur_departement_nom": "Paris", - "acheteur_commune_nom": "Paris", - "titulaire_departement_code": "35", - "titulaire_departement_nom": "Ille-et-Vilaine", - "titulaire_commune_nom": "Rennes", - "titulaire_distance": 10, - "titulaire_typeIdentifiant": "SIRET", - "objet": "Objet test", - "dureeRestanteMois": 12, - "lieuExecution_code": "75001", - "sourceFile": "test.xml", - "sourceDataset": "test_dataset", - "datePublicationDonnees": datetime.date(2025, 1, 1), - "considerationsSociales": "", - "considerationsEnvironnementales": "", - "type": "Marché", - "acheteur_categorie": "Collectivité", - "titulaire_categorie": "PME", - } - ] - parquet_path = Path(os.path.abspath("tests/test.parquet")) - db_path = parquet_path.parent / "decp.duckdb" - print(f"Writing test data to: {parquet_path}") - pl.DataFrame(data).write_parquet(parquet_path) - - # Remove any stale DuckDB from a previous run so src.db rebuilds from - # the freshly-written parquet at import time. - for artifact in (db_path, db_path.with_suffix(".duckdb.tmp")): +def _cleanup_db_artifacts() -> None: + for artifact in ( + _DB_PATH, + _DB_PATH.with_suffix(".duckdb.tmp"), + _DB_PATH.with_suffix(".duckdb.lock"), + ): if artifact.exists(): artifact.unlink() - yield str(parquet_path) + +# Runs at conftest import, before test modules import src.db (which builds the +# DuckDB at import time). Guarantees the test parquet exists and the stale DB +# from a previous `python run.py` is wiped so src.db rebuilds from test data. +pl.DataFrame(_TEST_DATA).write_parquet(_PARQUET_PATH) +_cleanup_db_artifacts() + + +@pytest.fixture(scope="session", autouse=True) +def test_data(): + yield str(_PARQUET_PATH) + # Teardown: remove the test DuckDB so the next `python run.py` rebuilds + # from decp_prod.parquet. + _cleanup_db_artifacts() def pytest_setup_options(): diff --git a/tests/test_db.py b/tests/test_db.py index 0ca9e36..bba7de5 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -141,6 +141,7 @@ def built_db(tmp_path, monkeypatch): ) data.write_parquet(parquet_path) monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path)) + monkeypatch.setenv("DUCKDB_PATH", str(db_path)) from src.db import build_database From f81c89734202c5c8d8523c1152a0ebb761f40332 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 20:47:55 +0200 Subject: [PATCH 010/151] Changelog 2.7.4 --- CHANGELOG.md | 4 + .../2026-04-21-tableau-performance-duckdb.md | 1016 +++++++++++++++++ 2 files changed, 1020 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-21-tableau-performance-duckdb.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ce56bf3..e7cfd94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +##### 2.7.4 (22 avril 2026) + +- Utilisation élargie de DuckDB au détriment de Polars => bien meilleure perf ([#72](https://github.com/ColinMaudry/decp.info/issues/72) + ##### 2.7.3 (20 avril 2026) - Mise en cache des vues tableau par ensemble de filtres et de tris diff --git a/docs/superpowers/plans/2026-04-21-tableau-performance-duckdb.md b/docs/superpowers/plans/2026-04-21-tableau-performance-duckdb.md new file mode 100644 index 0000000..fa887a8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-tableau-performance-duckdb.md @@ -0,0 +1,1016 @@ +# Plan : optimisation performance page Tableau via DuckDB + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal :** Réduire le temps de chargement _cold_ de `/tableau` (actuellement ~11 s sans filtre ni tri) à moins de 2 s, et le _warm_ (~5 s) à moins de 500 ms, en utilisant DuckDB comme moteur de requête (filtres, tri, pagination, comptage en SQL) plutôt que comme simple stockage. + +**Architecture :** + +1. Traduire le DSL de filtres de `dash_table.DataTable` (ex : `{objet} icontains travaux && {montant} i> 40000`) en SQL DuckDB paramétré. +2. Pousser `WHERE` / `ORDER BY` / `LIMIT` / `OFFSET` dans DuckDB plutôt que matérialiser 1,6 M lignes dans Polars. +3. Ne faire tourner le post-traitement coûteux (cast en String, liens HTML, formatage montants) **que sur la page courante** (20 lignes), pas sur le dataset complet. +4. Remplacer le cache `@cache.memoize()` sur la DataFrame complète par deux caches plus fins : un cache de `COUNT(*)` par `filter_query`, et un cache de pages par `(filter_query, sort_by, page_current, page_size)`. +5. Conserver intact le chemin existant `filter_table_data` (Polars LazyFrame) utilisé par `acheteur`, `titulaire`, `observatoire` qui passent déjà des données pré-filtrées via le paramètre `data`. + +**Tech Stack :** Python 3, Polars, DuckDB, Dash, Flask-Caching, pytest. + +**Périmètre explicite :** + +- **Inclus :** chemin `data is None` dans `prepare_table_data` (utilisé uniquement par `/tableau`). +- **Exclus :** refactor des pages acheteur/titulaire/observatoire (bénéficieraient d'un suivi, mais le chemin n'est pas le goulet d'étranglement actuel). +- **Exclus :** projection des colonnes cachées dans le `SELECT` (pourrait être une optimisation de suite ; non-triviale car le navigateur a besoin du schéma complet pour `setup_table_columns`). +- **Exclus :** index DuckDB. DuckDB est un moteur analytique, il tire parti du stockage columnaire sans index B-tree. On mesure avant d'ajouter quoi que ce soit. + +--- + +## Structure de fichiers + +| Fichier | Opération | Responsabilité | +| ------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------- | +| `src/utils/table_sql.py` | **Créer** | Traducteurs : `filter_query_to_sql()` et `sort_by_to_sql()`. Pur, testable sans Dash ni DB. | +| `src/db.py` | **Modifier** | Ajouter `count_marches(where_sql, params)` et paramètre `offset` à `query_marches()`. | +| `src/utils/table.py` | **Modifier** | Nouveau chemin rapide SQL dans `_load_filter_sort_postprocess`. Découpage du post-traitement en deux étapes (full → page-seule). | +| `tests/test_table_sql.py` | **Créer** | Tests unitaires des traducteurs (pur, aucune DB). | +| `tests/test_table.py` | **Modifier** | Ajouter tests d'intégration nouveau chemin ; garder les tests existants verts. | +| `tests/test_db.py` | **Modifier** | Tests pour `count_marches` et `offset`. | + +--- + +## Task 0 : Setup (à décider avec l'utilisateur avant de lancer) + +**À clarifier avec l'utilisateur :** + +- Sur quelle branche travailler ? `feature/73_compte_utilisateur` a 13+ fichiers non-commités. Probablement besoin de les mettre de côté, puis `git flow feature start NN_tableau_perf_duckdb`. +- Un numéro d'issue existe-t-il (ex : dans le dépôt GitHub) ? Sinon en créer un. + +**Pas de code à écrire tant que ce point n'est pas tranché.** + +--- + +## Task 1 : Traducteur `filter_query` → SQL `WHERE` + +**Files:** + +- Create: `src/utils/table_sql.py` +- Test: `tests/test_table_sql.py` + +Le DSL de DataTable a cette forme (vu dans `src/utils/table.py:216-279`) : + +- Séparateur entre filtres : `" && "` +- Opérateurs : `icontains`, `i<`, `i>`, `s<`, `s>` (existants dans `split_filter_part`) +- Wildcards sur string : `texte*` (starts_with), `*texte` (ends_with), sinon `contains` insensible à la casse +- Sur numériques : `icontains N` signifie `= N` + +On s'appuie sur `split_filter_part` existant pour le parsing d'un fragment. + +- [ ] **Step 1 : Écrire le test unitaire en échec** + +Créer `tests/test_table_sql.py` : + +```python +import polars as pl +import pytest + + +SCHEMA = pl.Schema({ + "uid": pl.String, + "objet": pl.String, + "acheteur_id": pl.String, + "montant": pl.Float64, + "dureeMois": pl.Int64, + "dateNotification": pl.Date, +}) + + +def test_empty_filter_returns_true(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("", SCHEMA) + assert where == "TRUE" + assert params == [] + + +def test_icontains_string_is_case_insensitive_like(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{objet} icontains travaux", SCHEMA) + # ILIKE '%travaux%' en DuckDB + assert where == '"objet" IS NOT NULL AND "objet" <> \'\' AND "objet" ILIKE ?' + assert params == ["%travaux%"] + + +def test_icontains_with_trailing_wildcard_is_starts_with(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{acheteur_id} icontains 24350013900189*", SCHEMA) + assert where == '"acheteur_id" IS NOT NULL AND "acheteur_id" <> \'\' AND "acheteur_id" ILIKE ?' + assert params == ["24350013900189%"] + + +def test_icontains_with_leading_wildcard_is_ends_with(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{uid} icontains *2024", SCHEMA) + assert where == '"uid" IS NOT NULL AND "uid" <> \'\' AND "uid" ILIKE ?' + assert params == ["%2024"] + + +def test_numeric_greater_than(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{montant} i> 40000", SCHEMA) + assert where == '"montant" IS NOT NULL AND "montant" > ?' + assert params == [40000] + + +def test_numeric_less_than(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{montant} i< 1000", SCHEMA) + assert where == '"montant" IS NOT NULL AND "montant" < ?' + assert params == [1000] + + +def test_numeric_equality_via_icontains(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{dureeMois} icontains 12", SCHEMA) + assert where == '"dureeMois" IS NOT NULL AND "dureeMois" = ?' + assert params == [12] + + +def test_date_column_treated_as_string_ilike(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{dateNotification} icontains 2024*", SCHEMA) + # Les dates sont castées en String pour filtrage textuel (parité avec le chemin Polars) + assert "ILIKE" in where + assert params == ["2024%"] + + +def test_multiple_filters_joined_by_and(): + from src.utils.table_sql import filter_query_to_sql + + filter_query = "{objet} icontains voirie && {montant} i> 40000" + where, params = filter_query_to_sql(filter_query, SCHEMA) + assert " AND " in where + assert params == ["%voirie%", 40000] + + +def test_invalid_numeric_value_is_skipped(): + from src.utils.table_sql import filter_query_to_sql + + # Ne pas faire planter l'app si l'utilisateur tape n'importe quoi + where, params = filter_query_to_sql("{montant} i> notanumber", SCHEMA) + assert where == "TRUE" + assert params == [] + + +def test_unknown_column_is_skipped(): + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{inexistant} icontains foo", SCHEMA) + assert where == "TRUE" + assert params == [] + + +def test_escapes_identifier_with_quotes_not_concatenation(): + """Sanity check : les noms de colonnes sont entre guillemets doubles (identifiants SQL), + les valeurs sont passées en paramètres (pas concaténées).""" + from src.utils.table_sql import filter_query_to_sql + + where, params = filter_query_to_sql("{objet} icontains '; DROP TABLE decp; --", SCHEMA) + # La valeur doit être dans params, jamais dans where + assert "DROP TABLE" not in where + assert any("DROP TABLE" in str(p) for p in params) +``` + +- [ ] **Step 2 : Lancer le test pour vérifier l'échec** + +Run : `rtk uv run pytest tests/test_table_sql.py -v` +Expected : FAIL avec `ModuleNotFoundError: No module named 'src.utils.table_sql'`. + +- [ ] **Step 3 : Implémenter le traducteur** + +Créer `src/utils/table_sql.py` : + +```python +import polars as pl + +from src.utils import logger +from src.utils.table import split_filter_part + + +def filter_query_to_sql( + filter_query: str, schema: pl.Schema +) -> tuple[str, list]: + """Traduit le DSL de filtres de dash_table.DataTable en fragment SQL DuckDB. + + Retourne (where_clause, params) où where_clause est un fragment à injecter + après `WHERE` et params est la liste des valeurs à passer à + `cursor.execute(sql, params)`. Les identifiants de colonnes sont validés + contre le schéma fourni ; jamais concaténés avec des valeurs utilisateur. + """ + if not filter_query: + return "TRUE", [] + + clauses: list[str] = [] + params: list = [] + + for part in filter_query.split(" && "): + col_name, operator, raw_value = split_filter_part(part) + if not isinstance(col_name, str) or not isinstance(raw_value, str): + continue + + if col_name not in schema.names(): + logger.warning(f"Colonne inconnue ignorée : {col_name!r}") + continue + + col_type = str(schema[col_name]) + is_numeric = col_type.startswith("Int") or col_type.startswith("Float") + quoted_col = f'"{col_name}"' + + if is_numeric: + try: + value = int(raw_value) if col_type.startswith("Int") else float(raw_value) + except ValueError: + logger.warning(f"Valeur numérique invalide ignorée : {raw_value!r}") + continue + + if operator == "contains": + clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} = ?") + elif operator == ">": + clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} > ?") + elif operator == "<": + clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} < ?") + else: + logger.warning(f"Opérateur invalide pour numérique : {operator!r}") + continue + params.append(value) + continue + + # String / Date : toujours traité comme texte (parité avec Polars) + value = raw_value.strip('"') + col_is_date = col_type == "Date" + + if operator == "contains": + if value.endswith("*") and not value.startswith("*"): + like = value[:-1] + "%" + elif value.startswith("*") and not value.endswith("*"): + like = "%" + value[1:] + else: + like = "%" + value + "%" + # Pour les dates : CAST en TEXT côté DuckDB avant ILIKE + target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col + clauses.append( + f"{quoted_col} IS NOT NULL AND {target} <> '' AND {target} ILIKE ?" + ) + params.append(like) + elif operator in (">", "<"): + # Comparaison lexicographique (on cast en varchar pour les dates, + # ce qui reste correct car le format ISO est trié-stable) + target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col + clauses.append( + f"{quoted_col} IS NOT NULL AND {target} {operator} ?" + ) + params.append(value) + else: + logger.warning(f"Opérateur invalide pour chaîne : {operator!r}") + continue + + if not clauses: + return "TRUE", [] + return " AND ".join(clauses), params + + +def sort_by_to_sql(sort_by: list[dict], schema: pl.Schema) -> str: + """Traduit sort_by (format Dash) en clause ORDER BY DuckDB. + + Retourne '' si pas de tri (aucun ORDER BY à ajouter). + """ + if not sort_by: + return "" + + fragments: list[str] = [] + for entry in sort_by: + col = entry.get("column_id") + direction = entry.get("direction") + if col not in schema.names(): + logger.warning(f"Tri sur colonne inconnue ignoré : {col!r}") + continue + if direction not in ("asc", "desc"): + continue + fragments.append(f'"{col}" {direction.upper()} NULLS LAST') + + return ", ".join(fragments) +``` + +- [ ] **Step 4 : Lancer le test** + +Run : `rtk uv run pytest tests/test_table_sql.py -v` +Expected : PASS (12 tests). + +- [ ] **Step 5 : Ajouter les tests pour `sort_by_to_sql`** + +Ajouter à `tests/test_table_sql.py` : + +```python +def test_sort_by_empty(): + from src.utils.table_sql import sort_by_to_sql + assert sort_by_to_sql([], SCHEMA) == "" + assert sort_by_to_sql(None, SCHEMA) == "" + + +def test_sort_by_single_column_desc(): + from src.utils.table_sql import sort_by_to_sql + result = sort_by_to_sql([{"column_id": "montant", "direction": "desc"}], SCHEMA) + assert result == '"montant" DESC NULLS LAST' + + +def test_sort_by_multiple_columns_preserves_order(): + from src.utils.table_sql import sort_by_to_sql + result = sort_by_to_sql( + [ + {"column_id": "dateNotification", "direction": "desc"}, + {"column_id": "montant", "direction": "asc"}, + ], + SCHEMA, + ) + assert result == '"dateNotification" DESC NULLS LAST, "montant" ASC NULLS LAST' + + +def test_sort_by_ignores_unknown_column(): + from src.utils.table_sql import sort_by_to_sql + result = sort_by_to_sql([{"column_id": "fake", "direction": "asc"}], SCHEMA) + assert result == "" +``` + +- [ ] **Step 6 : Lancer les tests** + +Run : `rtk uv run pytest tests/test_table_sql.py -v` +Expected : PASS (16 tests). + +- [ ] **Step 7 : Commit** + +```bash +rtk git add src/utils/table_sql.py tests/test_table_sql.py +rtk pre-commit +rtk git add src/utils/table_sql.py tests/test_table_sql.py +rtk git commit -m "feat: ajouter traducteurs filter_query→SQL et sort_by→SQL" +``` + +--- + +## Task 2 : Ajouter `count_marches` et `offset` dans `src/db.py` + +**Files:** + +- Modify: `src/db.py` +- Modify: `tests/test_db.py` + +- [ ] **Step 1 : Écrire les tests en échec** + +Ajouter à `tests/test_db.py` : + +```python +def test_count_marches_returns_total_without_filter(): + from src.db import count_marches + n = count_marches() + # Le dataset de test a forcément au moins une ligne + assert isinstance(n, int) + assert n > 0 + + +def test_count_marches_with_filter(): + from src.db import count_marches + # Une condition qui ne match rien doit retourner 0 + n = count_marches('"uid" = ?', ["__nonexistent__"]) + assert n == 0 + + +def test_query_marches_with_offset(): + from src.db import query_marches + page_0 = query_marches(limit=2, offset=0) + page_1 = query_marches(limit=2, offset=2) + # Les deux pages ne partagent aucune ligne (sauf dataset < 4 lignes) + if page_0.height == 2 and page_1.height >= 1: + assert set(page_0["uid"].to_list()).isdisjoint(set(page_1["uid"].to_list())) +``` + +- [ ] **Step 2 : Vérifier l'échec** + +Run : `rtk uv run pytest tests/test_db.py -v -k "count_marches or offset"` +Expected : ERROR `cannot import name 'count_marches'` + TypeError pour `offset`. + +- [ ] **Step 3 : Implémenter** + +Modifier `src/db.py`. + +Remplacer la signature de `query_marches` pour ajouter `offset` : + +```python +def query_marches( + where_sql: str = "TRUE", + params: tuple | list = (), + columns: list[str] | None = None, + order_by: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> pl.DataFrame: + """Run a parameterized SELECT against the decp table and return Polars. + + `where_sql` and `order_by` are trusted SQL fragments (callers are internal + code, never user input). `params` values are passed through DuckDB's + parameter binding. + """ + cols = ", ".join(columns) if columns else "*" + sql = f"SELECT {cols} FROM decp WHERE {where_sql}" + if order_by: + sql += f" ORDER BY {order_by}" + if limit is not None: + sql += f" LIMIT {int(limit)}" + if offset is not None: + sql += f" OFFSET {int(offset)}" + return get_cursor().execute(sql, list(params)).pl() +``` + +Ajouter à la fin de `src/db.py` : + +```python +def count_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int: + """Retourne le nombre de lignes correspondant à where_sql. + + Utilisé pour afficher `X lignes` au-dessus de la table sans matérialiser + les lignes en mémoire. + """ + sql = f"SELECT COUNT(*) FROM decp WHERE {where_sql}" + result = get_cursor().execute(sql, list(params)).fetchone() + return int(result[0]) if result else 0 +``` + +- [ ] **Step 4 : Lancer les tests** + +Run : `rtk uv run pytest tests/test_db.py -v` +Expected : PASS (tests existants + 3 nouveaux). + +- [ ] **Step 5 : Commit** + +```bash +rtk git add src/db.py tests/test_db.py +rtk pre-commit +rtk git add src/db.py tests/test_db.py +rtk git commit -m "feat(db): ajouter count_marches et paramètre offset à query_marches" +``` + +--- + +## Task 3 : Découper le post-traitement en deux étapes + +Actuellement `table_postprocess` (dans `src/utils/table.py:401-410`) fait : + +1. cast de toutes les colonnes en String +2. `fill_null("")` +3. `add_links` (ajoute des `` autour de uid, acheteur_nom, titulaire_nom, acheteur_id, titulaire_id) +4. `add_resource_link` (si `sourceFile` présent) +5. `format_values` (formatage des montants et distances) + +Ces opérations sont **idempotentes au niveau ligne** : les faire sur 20 lignes au lieu de 1,6 M donne exactement le même résultat pour les 20 lignes affichées. + +**Files:** + +- Modify: `src/utils/table.py` +- Modify: `tests/test_table.py` + +- [ ] **Step 1 : Écrire le test en échec** + +Ajouter à `tests/test_table.py` : + +```python +def test_postprocess_page_produces_same_result_as_full_then_slice(flask_app, sample_lff): + """Post-traiter 20 lignes doit donner le même résultat que post-traiter + l'ensemble puis slicer.""" + from src.utils import table + + full = sample_lff.collect() + with flask_app.app_context(): + via_full = table.table_postprocess(full.lazy()).slice(0, 1) + via_page = table.postprocess_page(full.slice(0, 1)) + # Mêmes colonnes, mêmes valeurs + assert via_full.columns == via_page.columns + for col in via_full.columns: + assert via_full[col].to_list() == via_page[col].to_list() +``` + +- [ ] **Step 2 : Vérifier l'échec** + +Run : `rtk uv run pytest tests/test_table.py::test_postprocess_page_produces_same_result_as_full_then_slice -v` +Expected : FAIL `module 'src.utils.table' has no attribute 'postprocess_page'`. + +- [ ] **Step 3 : Implémenter `postprocess_page`** + +Dans `src/utils/table.py`, juste sous `table_postprocess` (ligne ~401), ajouter : + +```python +def postprocess_page(dff: pl.DataFrame) -> pl.DataFrame: + """Post-traitement à appliquer sur une page déjà paginée. + + Équivalent à `table_postprocess` mais prend une DataFrame (pas un LazyFrame) + et ne matérialise rien de plus. À appeler après que la pagination ait été + poussée en SQL. + """ + dff = dff.with_columns(pl.all().cast(pl.String).fill_null("")) + dff = add_links(dff) + if "sourceFile" in dff.columns: + dff = add_resource_link(dff) + if dff.height > 0: + dff = format_values(dff) + return dff +``` + +- [ ] **Step 4 : Lancer les tests** + +Run : `rtk uv run pytest tests/test_table.py -v` +Expected : tous les tests PASS (existants + nouveau). + +- [ ] **Step 5 : Commit** + +```bash +rtk git add src/utils/table.py tests/test_table.py +rtk pre-commit +rtk git add src/utils/table.py tests/test_table.py +rtk git commit -m "feat(table): ajouter postprocess_page pour post-traiter une page seule" +``` + +--- + +## Task 4 : Nouveau chemin rapide SQL dans `_load_filter_sort_postprocess` + +C'est le cœur du plan. On remplace le chemin `data is None` de `prepare_table_data` pour faire filtre/tri/pagination en SQL DuckDB et post-traiter uniquement la page. + +**Files:** + +- Modify: `src/utils/table.py` +- Modify: `tests/test_table.py` + +**Contrat du nouveau helper** : au lieu de retourner la DataFrame complète post-traitée, retourner un tuple `(page_dff, total_count)`. Le cache s'applique sur la page et sur le count séparément. + +- [ ] **Step 1 : Écrire le test en échec** + +Ajouter à `tests/test_table.py` (en utilisant une vraie DB de test via la fixture existante de conftest si elle existe ; sinon voir `tests/test_db.py` pour le pattern) : + +```python +def test_fetch_page_sql_respects_pagination(flask_app): + """Nouveau chemin : retourne (page_dff, total_count) via DuckDB.""" + from src.utils import table + + with flask_app.app_context(): + page, total = table._fetch_page_sql( + filter_query=None, sort_by_key=(), page_current=0, page_size=5 + ) + assert page.height <= 5 + assert total >= page.height + + +def test_fetch_page_sql_pagination_returns_distinct_pages(flask_app): + from src.utils import table + + with flask_app.app_context(): + p0, total = table._fetch_page_sql( + filter_query=None, sort_by_key=(), page_current=0, page_size=2 + ) + if total >= 4: + p1, _ = table._fetch_page_sql( + filter_query=None, sort_by_key=(), page_current=1, page_size=2 + ) + assert set(p0["uid"].to_list()).isdisjoint(set(p1["uid"].to_list())) + + +def test_fetch_page_sql_applies_filter(flask_app): + from src.utils import table + + with flask_app.app_context(): + page, total = table._fetch_page_sql( + filter_query="{uid} icontains __ne_matche_rien__", + sort_by_key=(), + page_current=0, + page_size=20, + ) + assert total == 0 + assert page.height == 0 + + +def test_fetch_page_sql_applies_sort(flask_app): + from src.utils import table + + with flask_app.app_context(): + page, _ = table._fetch_page_sql( + filter_query=None, + sort_by_key=(("uid", "asc"),), + page_current=0, + page_size=10, + ) + uids = page["uid"].to_list() + assert uids == sorted(uids) + + +def test_fetch_page_sql_post_processes_links(flask_app): + from src.utils import table + + with flask_app.app_context(): + page, _ = table._fetch_page_sql( + filter_query=None, sort_by_key=(), page_current=0, page_size=1 + ) + if page.height > 0: + assert " tuple[pl.DataFrame, int]: + """Chemin rapide : filtre/tri/pagine dans DuckDB, post-traite la page seule. + + Retourne (page_dataframe_post_traitée, total_count_avant_pagination). + """ + logger.debug( + f"Cache miss SQL — filter={filter_query!r} sort={sort_by_key!r} " + f"page={page_current} size={page_size}" + ) + + where_sql, params = filter_query_to_sql(filter_query or "", schema) + + sort_by_dash = [ + {"column_id": col, "direction": direction} for col, direction in sort_by_key + ] + order_by = sort_by_to_sql(sort_by_dash, schema) or None + + total = count_marches(where_sql, params) + + page = query_marches( + where_sql=where_sql, + params=params, + order_by=order_by, + limit=page_size, + offset=page_current * page_size, + ) + + page = postprocess_page(page) + return page, total +``` + +- [ ] **Step 4 : Brancher `prepare_table_data` sur le nouveau chemin** + +Dans `src/utils/table.py`, dans `prepare_table_data`, remplacer le bloc `if data is None` (lignes ~435-439) : + +```python + if data is None: + sort_by_key = normalize_sort_by(sort_by) + dff, total = _fetch_page_sql( + filter_query=filter_query, + sort_by_key=sort_by_key, + page_current=page_current, + page_size=page_size, + ) + height = total + # La pagination a déjà eu lieu en SQL : NE PAS re-slicer plus bas. + already_paginated = True + else: + already_paginated = False + if isinstance(data, list): + lff: pl.LazyFrame = pl.LazyFrame( + data, strict=False, infer_schema_length=5000 + ) + elif isinstance(data, pl.LazyFrame): + lff = data + else: + lff = query_marches().lazy() + + if filter_query: + lff = filter_table_data(lff, filter_query) + + if sort_by and len(sort_by) > 0: + lff = sort_table_data(lff, sort_by) + + dff: pl.DataFrame = table_postprocess(lff) + height = dff.height +``` + +Puis, plus bas dans la même fonction, remplacer le bloc qui slice : + +```python + if height > 0: + nb_rows = ( + f"{format_number(height)} lignes " + f"({format_number(dff.select('uid').unique().height if not already_paginated else total_unique(filter_query))} marchés)" + ) + else: + nb_rows = "0 lignes (0 marchés)" + + if not already_paginated: + start_row = page_current * page_size + dff = dff.slice(start_row, page_size) +``` + +**Note :** le compte `uid.unique()` pose un problème : sur la page paginée il n'a pas de sens. On doit aussi le récupérer via DuckDB. Voir Step 5. + +- [ ] **Step 5 : Ajouter `count_unique_marches` dans db.py** + +Ajouter à `src/db.py` : + +```python +def count_unique_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int: + """Retourne le nombre de uid distincts correspondant à where_sql. + + Utilisé pour afficher `(X marchés)` — un uid peut apparaître plusieurs fois + si un marché a plusieurs titulaires. + """ + sql = f"SELECT COUNT(DISTINCT uid) FROM decp WHERE {where_sql}" + result = get_cursor().execute(sql, list(params)).fetchone() + return int(result[0]) if result else 0 +``` + +Et modifier `_fetch_page_sql` pour renvoyer aussi le count unique : + +```python + total = count_marches(where_sql, params) + total_unique = count_unique_marches(where_sql, params) + ... + return page, total, total_unique +``` + +Et adapter `prepare_table_data` en conséquence : + +```python + dff, total, total_unique = _fetch_page_sql(...) + height = total + already_paginated = True +``` + +Puis le message `nb_rows` : + +```python + if already_paginated: + if height > 0: + nb_rows = ( + f"{format_number(height)} lignes " + f"({format_number(total_unique)} marchés)" + ) + else: + nb_rows = "0 lignes (0 marchés)" + else: + if height > 0: + nb_rows = ( + f"{format_number(height)} lignes " + f"({format_number(dff.select('uid').unique().height)} marchés)" + ) + else: + nb_rows = "0 lignes (0 marchés)" +``` + +- [ ] **Step 6 : Lancer TOUS les tests** + +Run : `rtk uv run pytest tests/test_table.py tests/test_table_sql.py tests/test_db.py -v` +Expected : tous les tests PASS, y compris le test existant `test_prepare_table_data_paginates_without_recomputing` (la cache-key ne contient plus `page_current`, donc la deuxième page fera un cache-miss — attention, ce test doit être mis à jour, voir Step 7). + +- [ ] **Step 7 : Mettre à jour `test_prepare_table_data_paginates_without_recomputing`** + +Le test existant suppose l'ancien design (cache stocke la DF entière, pagination gratuite après). Le nouveau design cache par page. Adapter : + +Remplacer dans `tests/test_table.py` la fonction existante (ligne ~207) par : + +```python +def test_prepare_table_data_same_page_uses_cache( + monkeypatch, flask_app +): + """Deux appels avec exactement les mêmes (filter, sort, page, size) + doivent frapper le cache au deuxième coup.""" + from src.utils import table + + call_count = {"n": 0} + + real_fetch = table._fetch_page_sql.uncached if hasattr(table._fetch_page_sql, "uncached") else None + + def counting_fetch(*args, **kwargs): + call_count["n"] += 1 + # renvoyer un tuple minimal valide + import polars as pl + return pl.DataFrame({"uid": [], "acheteur_id": [], "titulaire_id": [], + "titulaire_typeIdentifiant": []}), 0, 0 + + # Patch la fonction sous-jacente avant memoize : remplacer par un mock + monkeypatch.setattr(table, "_fetch_page_sql", counting_fetch) + + with flask_app.app_context(): + table.prepare_table_data( + data=None, data_timestamp=0, filter_query=None, + page_current=0, page_size=10, sort_by=[], source_table="tableau", + ) + table.prepare_table_data( + data=None, data_timestamp=0, filter_query=None, + page_current=0, page_size=10, sort_by=[], source_table="tableau", + ) + # Le deuxième appel peut passer par le cache OU re-fire le mock + # (flask-caching mémoïze la fonction décorée, pas le mock). On valide + # juste que la plomberie appelle la bonne fonction. + assert call_count["n"] >= 1 +``` + +**Note importante :** flask-caching ne cachera pas le mock (il cache la fonction décorée d'origine). Ce test vérifie surtout que la plomberie est correcte. Pour tester vraiment le cache, ajouter un test en intégration avec une vraie DB, voir Task 6. + +- [ ] **Step 8 : Supprimer l'ancien `_load_filter_sort_postprocess`** + +Maintenant que le chemin rapide marche, supprimer la fonction `_load_filter_sort_postprocess` (inutilisée). Vérifier avec `rtk grep -rn "_load_filter_sort_postprocess" src/ tests/` qu'il n'y a plus aucune référence (sauf éventuellement les tests à nettoyer). + +**Si** des tests existants y font référence (ex : `test_load_filter_sort_postprocess_*`), les supprimer — ils testent un chemin qui n'existe plus. + +- [ ] **Step 9 : Lancer tous les tests à nouveau** + +Run : `rtk uv run pytest -v` +Expected : PASS. + +- [ ] **Step 10 : Commit** + +```bash +rtk git add src/db.py src/utils/table.py tests/test_table.py +rtk pre-commit +rtk git add src/db.py src/utils/table.py tests/test_table.py +rtk git commit -m "perf(tableau): pousser filtre/tri/pagination/comptage dans DuckDB" +``` + +--- + +## Task 5 : Test d'intégration end-to-end + mesure de performance + +**Files:** + +- Modify: `tests/test_main.py` (vérifier que la page /tableau se charge toujours) + +- [ ] **Step 1 : Lancer l'appli en local** + +```bash +rtk uv run run.py +``` + +Ouvrir http://localhost:8050/tableau dans un navigateur. + +- [ ] **Step 2 : Vérifier à la main** + + - La page se charge (affiche 20 lignes) + - Les liens `` sont présents dans les colonnes uid, acheteur_nom, titulaire_nom + - Les montants sont formatés (`12 500 €`) + - Appliquer un filtre texte (ex : `{objet} icontains voirie`), vérifier que la page se met à jour + - Appliquer un filtre numérique (ex : `{montant} i> 40000`), idem + - Cliquer sur le tri d'une colonne, vérifier que ça fonctionne + - Changer de page (20 → 40 → 60), vérifier que ça marche + - Partager une URL avec filtres+tris+colonnes, ouvrir dans un autre onglet, vérifier que l'état est restauré + - Téléchargement Excel fonctionne encore (doit passer par le chemin `query_marches().lazy()` qui n'a pas été touché) + +- [ ] **Step 3 : Mesurer à froid** + +Redémarrer l'appli (kill + `rtk uv run run.py`). Ouvrir `/tableau` sans filtre. Noter le temps (onglet Network → finished). Recharger plusieurs fois, noter warm. + +- [ ] **Step 4 : Mesurer avec filtre** + +Appliquer un filtre fréquent (ex : `{acheteur_departement_code} icontains 35`). Noter cold (premier filtre) et warm (mêmes filtre appliqué après un `Reset`). + +- [ ] **Step 5 : Si les perfs ne sont pas au rendez-vous** + +Debugger : + +- `rtk uv run python -c "import duckdb, time; c = duckdb.connect('./decp.duckdb', read_only=True); t=time.time(); r=c.execute('SELECT * FROM decp ORDER BY dateNotification DESC LIMIT 20').pl(); print(time.time()-t, r.height)"` +- Si DuckDB lui-même est lent sur cette requête (> 1 s), envisager `CREATE INDEX` sur `dateNotification` (bien que les index B-tree aient un bénéfice limité sur DuckDB columnaire — vérifier avec EXPLAIN ANALYZE). +- Vérifier que `postprocess_page` s'exécute bien sur 20 lignes, pas plus. Ajouter un `logger.debug(f"postprocess {dff.height} rows")`. + +- [ ] **Step 6 : Documenter les résultats** + +Ajouter à la fin de ce fichier plan (ou dans un nouveau fichier `docs/superpowers/plans/2026-04-21-tableau-performance-duckdb-results.md`) : + +``` +## Résultats mesurés + +| Scénario | Avant | Après | +|---|---|---| +| Cold, sans filtre | 11 s | X s | +| Warm, sans filtre | 5 s | X s | +| Cold, avec filtre texte | ? | X s | +| Warm, avec filtre texte | ? | X s | +| Changement de page | ? | X s | +| Tri sur colonne | ? | X s | +``` + +- [ ] **Step 7 : Commit du plan avec résultats** + +```bash +rtk git add docs/superpowers/plans/ +rtk pre-commit +rtk git add docs/superpowers/plans/ +rtk git commit -m "docs: résultats perf tableau après optimisation DuckDB" +``` + +--- + +## Task 6 : Non-régression sur les autres pages qui utilisent `prepare_table_data` + +**Files:** + +- Smoke test manuel sur `/acheteurs/`, `/titulaires/`, `/observatoire` + +- [ ] **Step 1 : Lancer l'appli et naviguer sur chaque page** + +Lancer : `rtk uv run run.py` + +Puis : + +1. `/recherche` → rechercher un acheteur → cliquer sur un résultat → la page `/acheteurs/` doit afficher le tableau des marchés de l'acheteur avec les mêmes liens `` et formats (les tests passent déjà, c'est juste un smoke test UI). +2. Idem sur `/titulaires/`. +3. Aller sur `/observatoire`, vérifier que la datatable en bas de page affiche les marchés et que les liens/formats fonctionnent. + +- [ ] **Step 2 : Lancer la suite Selenium** + +Run : `rtk uv run pytest tests/test_main.py -v` +Expected : PASS (ou si échec, comprendre s'il est lié à la modif ou à un flake Selenium). + +- [ ] **Step 3 : Si tout est vert, commit de validation** + +(Aucun code à commiter normalement ; c'est juste un check final.) + +--- + +## Self-Review + +Cette section est ma relecture du plan avant remise à l'utilisateur. + +**Couverture du spec :** + +- ✅ Pousser WHERE/ORDER BY/LIMIT/OFFSET dans DuckDB → Tasks 1-4 +- ✅ Ne post-traiter que la page courante → Task 3 +- ✅ `COUNT(*)` en SQL au lieu de `.height` sur DF matérialisée → Task 2 +- ✅ Préserver les autres pages → Task 6 +- ⚠️ Index DuckDB : explicitement hors scope, à décider après mesure (Task 5 Step 5). +- ⚠️ Projection des colonnes cachées : hors scope (mentionné dans le périmètre). +- ⚠️ Cache client-side : rejeté dans la discussion initiale, pas dans le plan. + +**Chasse aux placeholders :** pas de "TBD", pas de "implement later". Tous les steps ont du code concret. + +**Cohérence des types :** + +- `filter_query_to_sql(filter_query, schema) → (str, list)` — utilisé Task 4 Step 3 de la même façon. ✓ +- `sort_by_to_sql(sort_by, schema) → str` — idem. ✓ +- `_fetch_page_sql(filter_query, sort_by_key, page_current, page_size) → (pl.DataFrame, int, int)` après Step 5. Attention : j'ai d'abord écrit `(DataFrame, int)` à deux endroits (Task 4 Step 3 & 4) puis élargi à `(DataFrame, int, int)` au Step 5. **Fix inline :** Step 3 et Step 4 doivent dès le départ renvoyer le triplet pour éviter une double-édition. J'ajoute la note ci-dessous. + +**Fix inline (ne pas éditer à l'implémentation, juste suivre la note) :** dans Task 4 Step 3, `_fetch_page_sql` doit retourner `(page, total, total_unique)` et appeler `count_marches` + `count_unique_marches`. Le tuple-à-deux-éléments dans le test Step 1 doit aussi être un triplet : + +```python +page, total, total_unique = table._fetch_page_sql(...) +``` + +De même, `count_unique_marches` doit être ajouté dans Task 2 (pas dans Task 4 Step 5), avec son propre test. **Mise à jour du plan :** ajouter à Task 2 Step 3 : + +```python +def count_unique_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int: + sql = f"SELECT COUNT(DISTINCT uid) FROM decp WHERE {where_sql}" + result = get_cursor().execute(sql, list(params)).fetchone() + return int(result[0]) if result else 0 +``` + +et son test dans Task 2 Step 1 : + +```python +def test_count_unique_marches_respects_distinct(): + from src.db import count_unique_marches + n = count_unique_marches() + assert isinstance(n, int) + assert n > 0 +``` + +Avec ça, Task 4 Step 5 devient trivial (le helper existe déjà). + +**Décisions en suspens à confirmer avec l'utilisateur avant exécution :** + +1. **Branche** : rester sur `feature/73_compte_utilisateur` (couplée à `#73 compte utilisateur`, aucun rapport) semble incorrect. Créer `feature/NN_tableau_performance` après stash/commit des fichiers en cours. +2. **Numéro d'issue** : à créer côté GitHub si pertinent pour la release note. +3. **Suppression ou conservation de `_load_filter_sort_postprocess`** : le plan le supprime à Task 4 Step 8. Alternative : le garder en tant que legacy pendant une version pour comparaison de perf en prod. Par défaut : supprimer (YAGNI). From 8e603d28066641bed600a011bb1880def2c9d179 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 20:49:57 +0200 Subject: [PATCH 011/151] Bump version number --- README.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9a52e8f..4735a79 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # decp.info -> v2.7.3 +> v2.7.4 > Outil d'exploration et de téléchargement des données essentielles de la commande publique. => [decp.info](https://decp.info) diff --git a/pyproject.toml b/pyproject.toml index 212d0bd..e6cb9a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "decp.info" description = "Interface d'exploration et d'analyse des marchés publics français." -version = "2.7.3" +version = "2.7.4" requires-python = ">= 3.10" authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }] dependencies = [ From eb8d7abe0d57b1897e5a6c5de43a7ca7bf6fce78 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 20:55:22 +0200 Subject: [PATCH 012/151] actions/checkout@v4 --- .github/workflows/deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 9d9f9e4..dbd7476 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -20,7 +20,7 @@ jobs: environment: ${{ github.ref_name }} steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up SSH key run: | From 3e89dacff91dc3ae860b487a23b92c9ae2e4b795 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 21:16:51 +0200 Subject: [PATCH 013/151] Correction de setup_table_columns et autres --- src/app.py | 1 + src/figures.py | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app.py b/src/app.py index d5ddb6f..5e9e490 100644 --- a/src/app.py +++ b/src/app.py @@ -2,6 +2,7 @@ import os from shutil import rmtree import dash_bootstrap_components as dbc +import pandas # noqa: F401 # eager import: avoid plotly's lazy-import race across Dash callback threads import tomllib from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry from dotenv import load_dotenv diff --git a/src/figures.py b/src/figures.py index e799b4f..b89e395 100644 --- a/src/figures.py +++ b/src/figures.py @@ -839,7 +839,7 @@ def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = return html.Div() columns, tooltip = setup_table_columns( - dff, hideable=False, exclude=[f"{org_type}_id"], new_columns=["Attributions"] + dff, hideable=False, exclude=[f"{org_type}_id"] ) dff = add_links(dff) data = dff.to_dicts() diff --git a/uv.lock b/uv.lock index 0fd8ca1..ecfad9e 100644 --- a/uv.lock +++ b/uv.lock @@ -760,7 +760,7 @@ wheels = [ [[package]] name = "decp-info" -version = "2.7.3" +version = "2.7.4" source = { virtual = "." } dependencies = [ { name = "dash", extra = ["compress"] }, From b996eb97cc22a0d51804b5f66c3ae5b3ac0d3a9f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 23:16:41 +0200 Subject: [PATCH 014/151] docs(observatoire): spec du filtrage natif DuckDB (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Décrit la refonte de prepare_dashboard_data pour pousser le filtrage au niveau DuckDB via un nouveau helper dashboard_filters_to_sql, sur le modèle de filter_query_to_sql / _fetch_page_sql. --- ...4-22-observatoire-duckdb-filters-design.md | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-22-observatoire-duckdb-filters-design.md diff --git a/docs/superpowers/specs/2026-04-22-observatoire-duckdb-filters-design.md b/docs/superpowers/specs/2026-04-22-observatoire-duckdb-filters-design.md new file mode 100644 index 0000000..6d8ebaf --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-observatoire-duckdb-filters-design.md @@ -0,0 +1,206 @@ +# Observatoire — filtrage natif DuckDB + +## Contexte + +La page `/observatoire` construit ses cartes, ses téléchargements et sa prévisualisation +tabulaire à partir de la fonction `prepare_dashboard_data` (dans `src/utils/data.py`). +Aujourd'hui, cette fonction prend une `pl.LazyFrame` — typiquement obtenue par +`query_marches().lazy()` — et applique une série de filtres côté Polars. + +`query_marches()` matérialise l'intégralité de la table `decp` (~1,5 M lignes) en +DataFrame Polars, même lorsqu'un utilisateur applique des filtres restrictifs. Les +filtres sont ensuite appliqués sur cet ensemble déjà matérialisé. + +Le pattern utilisé par `_fetch_page_sql` (dans `src/utils/table.py`) montre comment +déléguer le filtrage à DuckDB : + +1. Un traducteur (`filter_query_to_sql`, dans `src/utils/table_sql.py`) transforme le + DSL utilisateur en `(where_sql, params)`. +2. `query_marches(where_sql=..., params=...)` ne matérialise que le sous-ensemble utile. + +Ce spec décrit comment appliquer ce même pattern aux filtres de l'observatoire. + +## Objectifs + +- Réduire la consommation mémoire et le temps de chaque callback de l'observatoire + en poussant le filtrage au niveau DuckDB. +- Conserver strictement la sémantique des filtres actuels (pas de régression + fonctionnelle). +- Garder une frontière claire : un helper pur `dashboard_filters_to_sql` qui ne + touche pas à la base, et une `prepare_dashboard_data` fine qui appelle DuckDB. + +## Non-objectifs + +- Pas de refonte de l'UI de filtres. +- Pas d'optimisation ou de cache supplémentaire autour de + `_compute_dashboard_children` (déjà `@cache.memoize()`). +- Pas de changement du comportement par défaut (365 derniers jours quand aucune + année n'est sélectionnée). + +## Architecture + +### Nouveau helper — `src/utils/table_sql.py` + +```python +def dashboard_filters_to_sql( + dashboard_year=None, + dashboard_acheteur_id=None, + dashboard_acheteur_categorie=None, + dashboard_acheteur_departement_code=None, + dashboard_titulaire_id=None, + dashboard_titulaire_categorie=None, + dashboard_titulaire_departement_code=None, + dashboard_marche_type=None, + dashboard_marche_objet=None, + dashboard_marche_code_cpv=None, + dashboard_marche_considerations_sociales=None, + dashboard_marche_considerations_environnementales=None, + dashboard_marche_techniques=None, + dashboard_marche_innovant=None, + dashboard_marche_sous_traitance_declaree=None, + dashboard_montant_min=None, + dashboard_montant_max=None, +) -> tuple[str, list]: + """Traduit les filtres du tableau de bord en (where_clause, params) DuckDB.""" +``` + +Fonction pure, sans accès à la base. Même signature que `prepare_dashboard_data` +actuelle (hors `lff`). Retourne `("TRUE", [])` si aucun filtre n'est actif. + +### Réécriture — `prepare_dashboard_data` (`src/utils/data.py`) + +```python +def prepare_dashboard_data(**filter_params) -> pl.DataFrame: + where_sql, params = dashboard_filters_to_sql(**filter_params) + return query_marches(where_sql=where_sql, params=params) +``` + +- **Signature** : suppression du paramètre `lff`. Retour `pl.DataFrame` (et non plus + `pl.LazyFrame`). +- Les appelants qui ont besoin d'une LazyFrame appellent `.lazy()` sur le résultat. + +### Appelants — `src/pages/observatoire.py` + +Trois sites d'appel à adapter : + +1. **`_compute_dashboard_children`** (ligne ~668) — on remplace + + ```python + lff: pl.LazyFrame = query_marches().lazy() + lff = prepare_dashboard_data(lff=lff, **filter_params) + dff = lff.collect(engine="streaming") + ``` + + par + + ```python + dff = prepare_dashboard_data(**filter_params) + lff = dff.lazy() + ``` + + Les appels existants à `make_donut`, `get_distance_histogram`, `get_top_org_table`, + `get_barchart_sources` continuent de recevoir `lff` ; `get_geographic_maps` + continue de recevoir `dff`. `df_per_uid` est calculé à partir de `dff`. + +2. **`download_observatoire`** (ligne ~791) — + + ```python + dff = prepare_dashboard_data(**(filter_params or {})) + if hidden_columns: + dff = dff.drop(hidden_columns) + def to_bytes(buffer): + dff.write_excel(buffer, worksheet="DECP") + ``` + +3. **`populate_preview_table`** (ligne ~882) — + ```python + dff = prepare_dashboard_data(**(filter_params or {})) + return prepare_table_data( + dff.lazy(), # prepare_table_data accepte une LazyFrame + ... + ) + ``` + +## Traduction des filtres + +| Filtre | Actuel (Polars) | Cible (SQL DuckDB) | +| --------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------- | +| `dashboard_year` (présent) | `dt.year() == int(year)` | `YEAR("dateNotification") = ?` | +| `dashboard_year` (absent) — comportement par défaut | `> now - 365j` | `"dateNotification" > ?` (datetime calculé à l'appel) | +| `dashboard_acheteur_id` | `str.contains(val)` | `"acheteur_id" LIKE ?` avec `%val%` | +| `dashboard_acheteur_categorie` | `== val` (skip si acheteur_id présent) | `"acheteur_categorie" = ?` | +| `dashboard_acheteur_departement_code` | `is_in(list)` (skip si acheteur_id présent) | `"acheteur_departement_code" IN (?, ?, ...)` | +| `dashboard_titulaire_id` | idem acheteur | idem | +| `dashboard_titulaire_categorie` | idem | idem | +| `dashboard_titulaire_departement_code` | idem | idem | +| `dashboard_marche_type` | `== val` | `"type" = ?` | +| `dashboard_marche_objet` | `str.contains("(?i)val")` | `"objet" ILIKE ?` avec `%val%` | +| `dashboard_marche_code_cpv` | `str.starts_with(val)` | `"codeCPV" LIKE ?` avec `val%` | +| `dashboard_marche_techniques` | `str.split(", ").list.set_intersection(xs).list.len() > 0` | `list_has_any(string_split("techniques", ', '), ?::VARCHAR[])` | +| `dashboard_marche_considerations_sociales` | idem | idem sur `"considerationsSociales"` | +| `dashboard_marche_considerations_environnementales` | idem | idem sur `"considerationsEnvironnementales"` | +| `dashboard_marche_innovant` (`"oui"`/`"non"`, sinon skip) | `== val` | `"marcheInnovant" = ?` | +| `dashboard_marche_sous_traitance_declaree` | idem | `"sousTraitanceDeclaree" = ?` | +| `dashboard_montant_min` | `>= val` | `"montant" >= ?` | +| `dashboard_montant_max` | `<= val` | `"montant" <= ?` | + +**Logique conditionnelle conservée** : si `dashboard_acheteur_id` est fourni, les filtres +`categorie` et `departement_code` acheteur sont ignorés (même chose pour titulaire). + +**Traitement des valeurs spéciales** : + +- `dashboard_marche_innovant` / `dashboard_marche_sous_traitance_declaree` : valeur + `"all"` ou falsy → aucun filtre ajouté. +- `dashboard_year` : converti en `int` avant injection. +- `dashboard_montant_min` / `_max` : `None` → aucun filtre (distinct de `0`, qui reste + un filtre valide via `>=` ou `<=`). + +**Sécurité SQL** : toutes les valeurs utilisateurs passent par DuckDB en paramètres liés +(`?`). Seuls des noms de colonnes statiques (contrôlés par le code) sont injectés dans le +fragment SQL via `f"..."`. Pas de différence avec le pattern existant de +`filter_query_to_sql`. + +## Tests + +### Unitaires (nouveaux) + +Nouveau fichier `tests/test_dashboard_filters_to_sql.py` : + +- Cas vide → `("TRUE", [])`. +- Un seul filtre simple (année, type, etc.) → fragment SQL et params attendus. +- Filtre montant min/max (migration de l'actuel `test_010_observatoire_montant_filter`). +- Filtre liste (techniques, considerationsSociales) → usage de `list_has_any`. +- Filtre acheteur_id fourni → catégorie/département acheteur ignorés. +- Filtre `"all"` / `None` sur innovant/sous_traitance → aucun fragment ajouté. +- Comportement par défaut sans année → fragment `"dateNotification" > ?` avec un param + datetime à ~365 j dans le passé (tolérance de quelques secondes). + +### Intégration (nouveau, léger) + +Un test qui appelle `prepare_dashboard_data` contre `tests/test.parquet` avec un ou +deux filtres connus, vérifie le `height` et la bonne nature du retour (`pl.DataFrame`). + +### Test Selenium existant + +`test_009_observatoire_filter_persistence` et `test_008_observatoire_navigation_from_search` +ne touchent pas à la signature ; ils doivent continuer à passer. + +## Risques et migration + +- **Risque sémantique** : la fonction Polars `str.contains` utilisée pour les IDs est + un regex. Les utilisateurs attendent probablement un contains littéral sur un SIRET + (14 chiffres). Le passage à `LIKE '%val%'` est neutre si la valeur ne contient pas de + caractère spécial regex — ce qui est le cas pour des SIRET. **Hypothèse** acceptée : + le contenu `dashboard_acheteur_id`/`dashboard_titulaire_id` est alphanumérique. +- **Risque de drift du cache** : la date "365 derniers jours" n'est pas incluse dans + la clé de cache de `_compute_dashboard_children`. C'est un comportement pré-existant + ; non traité par ce spec. +- **Import circulaire** : `src/utils/data.py` importe déjà depuis `src/db.py`. + `src/utils/table_sql.py` importe depuis `src/utils/table.py`. Pas de nouveau cycle. + +## Succès + +- Les 3 callbacks de l'observatoire restent fonctionnellement équivalents. +- Les tests unitaires et d'intégration passent. +- Une inspection manuelle confirme un temps d'exécution réduit sur un filtre + sélectif (par ex. un département + une année). From aaf54eef91f856b797698cc2a94d0072dfcff15e Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 23:21:47 +0200 Subject: [PATCH 015/151] =?UTF-8?q?docs(observatoire):=20plan=20d'impl?= =?UTF-8?q?=C3=A9mentation=20filtrage=20natif=20DuckDB=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan en 10 tâches TDD : construction incrémentale de dashboard_filters_to_sql, réécriture de prepare_dashboard_data, adaptation des 3 appelants de observatoire.py, test d'intégration sur tests/test.parquet. --- .../2026-04-22-observatoire-duckdb-filters.md | 951 ++++++++++++++++++ 1 file changed, 951 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-22-observatoire-duckdb-filters.md diff --git a/docs/superpowers/plans/2026-04-22-observatoire-duckdb-filters.md b/docs/superpowers/plans/2026-04-22-observatoire-duckdb-filters.md new file mode 100644 index 0000000..4029abb --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-observatoire-duckdb-filters.md @@ -0,0 +1,951 @@ +# Observatoire — filtrage natif DuckDB — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remplacer le filtrage Polars sur LazyFrame dans `prepare_dashboard_data` par un requêtage natif DuckDB, pour ne matérialiser que le sous-ensemble utile au lieu de l'intégralité de la table `decp` (~1,5 M lignes). + +**Architecture:** Nouveau helper pur `dashboard_filters_to_sql(**filter_params) -> (where_sql, params)` dans `src/utils/table_sql.py` (modèle de `filter_query_to_sql`). `prepare_dashboard_data` devient une fonction fine qui appelle `query_marches(where_sql, params)` et retourne une `pl.DataFrame`. Les 3 appelants dans `src/pages/observatoire.py` sont adaptés à la nouvelle signature. + +**Tech Stack:** Python 3.12, Polars, DuckDB, Dash, pytest. + +**Spec:** `docs/superpowers/specs/2026-04-22-observatoire-duckdb-filters-design.md`. + +--- + +## File Structure + +**À créer :** + +- `tests/test_dashboard_filters_to_sql.py` — tests unitaires du nouveau helper SQL (cas vide + cas par filtre). +- `tests/test_prepare_dashboard_data.py` — test d'intégration léger (appel DuckDB réel sur `tests/test.parquet`). + +**À modifier :** + +- `src/utils/table_sql.py` — ajouter `dashboard_filters_to_sql` + import `datetime`/`timedelta`. +- `src/utils/data.py` — réécrire `prepare_dashboard_data` (signature et implémentation), ajouter `query_marches` aux imports `from src.db`. +- `src/pages/observatoire.py` — adapter 3 sites d'appel (lignes ~668, ~791, ~882) ; retirer `query_marches` de l'import `from src.db` (plus utilisé). +- `tests/test_main.py` — supprimer `test_010_observatoire_montant_filter` (migré en test unitaire du helper). + +--- + +## Task 1: Tests unitaires — cas par défaut + filtre année + +**Files:** + +- Create: `tests/test_dashboard_filters_to_sql.py` +- Modify: `src/utils/table_sql.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_dashboard_filters_to_sql.py`: + +```python +from datetime import datetime, timedelta + +from src.utils.table_sql import dashboard_filters_to_sql + + +def test_no_filters_uses_default_365_day_window(): + where_sql, params = dashboard_filters_to_sql() + assert where_sql == '"dateNotification" > ?' + assert len(params) == 1 + assert isinstance(params[0], datetime) + expected = datetime.now() - timedelta(days=365) + assert abs((params[0] - expected).total_seconds()) < 2 + + +def test_year_filter_overrides_default_window(): + where_sql, params = dashboard_filters_to_sql(dashboard_year="2025") + assert where_sql == 'YEAR("dateNotification") = ?' + assert params == [2025] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` +Expected: FAIL with `ImportError: cannot import name 'dashboard_filters_to_sql'`. + +- [ ] **Step 3: Implement the helper** + +Add to the top of `src/utils/table_sql.py` (below existing imports): + +```python +from datetime import datetime, timedelta +``` + +Append this function at the end of `src/utils/table_sql.py`: + +```python +def dashboard_filters_to_sql( + dashboard_year=None, + dashboard_acheteur_id=None, + dashboard_acheteur_categorie=None, + dashboard_acheteur_departement_code=None, + dashboard_titulaire_id=None, + dashboard_titulaire_categorie=None, + dashboard_titulaire_departement_code=None, + dashboard_marche_type=None, + dashboard_marche_objet=None, + dashboard_marche_code_cpv=None, + dashboard_marche_considerations_sociales=None, + dashboard_marche_considerations_environnementales=None, + dashboard_marche_techniques=None, + dashboard_marche_innovant=None, + dashboard_marche_sous_traitance_declaree=None, + dashboard_montant_min=None, + dashboard_montant_max=None, +) -> tuple[str, list]: + """Traduit les filtres du tableau de bord en (where_clause, params) DuckDB.""" + clauses: list[str] = [] + params: list = [] + + if dashboard_year: + clauses.append('YEAR("dateNotification") = ?') + params.append(int(dashboard_year)) + else: + clauses.append('"dateNotification" > ?') + params.append(datetime.now() - timedelta(days=365)) + + return " AND ".join(clauses), params +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py +rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py +rtk git commit -m "feat(observatoire): squelette de dashboard_filters_to_sql (#72)" +``` + +--- + +## Task 2: Filtres d'égalité simples (catégorie, type, innovant, sous-traitance) + +**Files:** + +- Modify: `tests/test_dashboard_filters_to_sql.py` +- Modify: `src/utils/table_sql.py` + +- [ ] **Step 1: Add failing tests** + +Append to `tests/test_dashboard_filters_to_sql.py`: + +```python +def test_marche_type_equality(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_type="Marché", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "type" = ?' + assert params == [2025, "Marché"] + + +def test_innovant_value_all_is_skipped(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_innovant="all", + ) + assert where_sql == 'YEAR("dateNotification") = ?' + assert params == [2025] + + +def test_innovant_value_oui_adds_clause(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_innovant="oui", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "marcheInnovant" = ?' + assert params == [2025, "oui"] + + +def test_sous_traitance_value_non_adds_clause(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_sous_traitance_declaree="non", + ) + assert ( + where_sql + == 'YEAR("dateNotification") = ? AND "sousTraitanceDeclaree" = ?' + ) + assert params == [2025, "non"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` +Expected: 4 new tests FAIL (missing clauses). + +- [ ] **Step 3: Extend the helper** + +Insert the following block in `dashboard_filters_to_sql`, **after** the `if dashboard_year / else` block and **before** `return " AND ".join(clauses), params`: + +```python + if dashboard_marche_type: + clauses.append('"type" = ?') + params.append(dashboard_marche_type) + + if dashboard_marche_innovant and dashboard_marche_innovant != "all": + clauses.append('"marcheInnovant" = ?') + params.append(dashboard_marche_innovant) + + if ( + dashboard_marche_sous_traitance_declaree + and dashboard_marche_sous_traitance_declaree != "all" + ): + clauses.append('"sousTraitanceDeclaree" = ?') + params.append(dashboard_marche_sous_traitance_declaree) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` +Expected: PASS (6 tests total). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py +rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py +rtk git commit -m "feat(observatoire): filtres d'égalité simples dans dashboard_filters_to_sql (#72)" +``` + +--- + +## Task 3: Filtres LIKE/ILIKE (ids, objet, cpv) + +**Files:** + +- Modify: `tests/test_dashboard_filters_to_sql.py` +- Modify: `src/utils/table_sql.py` + +- [ ] **Step 1: Add failing tests** + +Append to `tests/test_dashboard_filters_to_sql.py`: + +```python +def test_acheteur_id_uses_like_wildcards(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_acheteur_id="12345678900010", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?' + assert params == [2025, "%12345678900010%"] + + +def test_titulaire_id_uses_like_wildcards(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_titulaire_id="999", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?' + assert params == [2025, "%999%"] + + +def test_marche_objet_uses_case_insensitive_ilike(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_objet="travaux", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "objet" ILIKE ?' + assert params == [2025, "%travaux%"] + + +def test_code_cpv_uses_prefix_like(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_code_cpv="4521", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "codeCPV" LIKE ?' + assert params == [2025, "4521%"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` +Expected: 4 new tests FAIL. + +- [ ] **Step 3: Extend the helper** + +Insert the following block, **just after** the year/default block and **before** the `if dashboard_marche_type` block: + +```python + if dashboard_acheteur_id: + clauses.append('"acheteur_id" LIKE ?') + params.append(f"%{dashboard_acheteur_id}%") + + if dashboard_titulaire_id: + clauses.append('"titulaire_id" LIKE ?') + params.append(f"%{dashboard_titulaire_id}%") +``` + +Insert in the "marché" block, **after** `dashboard_marche_type` and **before** `dashboard_marche_innovant`: + +```python + if dashboard_marche_objet: + clauses.append('"objet" ILIKE ?') + params.append(f"%{dashboard_marche_objet}%") + + if dashboard_marche_code_cpv: + clauses.append('"codeCPV" LIKE ?') + params.append(f"{dashboard_marche_code_cpv}%") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` +Expected: PASS (10 tests total). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py +rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py +rtk git commit -m "feat(observatoire): filtres LIKE/ILIKE dans dashboard_filters_to_sql (#72)" +``` + +--- + +## Task 4: Filtre IN (départements) + skip conditionnel par ID + +**Files:** + +- Modify: `tests/test_dashboard_filters_to_sql.py` +- Modify: `src/utils/table_sql.py` + +- [ ] **Step 1: Add failing tests** + +Append to `tests/test_dashboard_filters_to_sql.py`: + +```python +def test_acheteur_departement_multiple_uses_in_clause(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_acheteur_departement_code=["75", "92", "93"], + ) + assert where_sql == ( + 'YEAR("dateNotification") = ? ' + 'AND "acheteur_departement_code" IN (?, ?, ?)' + ) + assert params == [2025, "75", "92", "93"] + + +def test_acheteur_categorie_adds_clause(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_acheteur_categorie="Commune", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_categorie" = ?' + assert params == [2025, "Commune"] + + +def test_titulaire_categorie_and_departement(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_titulaire_categorie="PME", + dashboard_titulaire_departement_code=["35"], + ) + assert where_sql == ( + 'YEAR("dateNotification") = ? ' + 'AND "titulaire_categorie" = ? ' + 'AND "titulaire_departement_code" IN (?)' + ) + assert params == [2025, "PME", "35"] + + +def test_acheteur_id_present_skips_categorie_and_departement(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_acheteur_id="123", + dashboard_acheteur_categorie="Commune", + dashboard_acheteur_departement_code=["75"], + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?' + assert params == [2025, "%123%"] + + +def test_titulaire_id_present_skips_categorie_and_departement(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_titulaire_id="999", + dashboard_titulaire_categorie="PME", + dashboard_titulaire_departement_code=["35"], + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?' + assert params == [2025, "%999%"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` +Expected: 5 new tests FAIL. + +- [ ] **Step 3: Refactor the helper with conditional skip** + +Replace the two simple `if dashboard_acheteur_id` / `if dashboard_titulaire_id` blocks added in Task 3 with the nested form: + +```python + if dashboard_acheteur_id: + clauses.append('"acheteur_id" LIKE ?') + params.append(f"%{dashboard_acheteur_id}%") + else: + if dashboard_acheteur_categorie: + clauses.append('"acheteur_categorie" = ?') + params.append(dashboard_acheteur_categorie) + if dashboard_acheteur_departement_code: + placeholders = ", ".join(["?"] * len(dashboard_acheteur_departement_code)) + clauses.append(f'"acheteur_departement_code" IN ({placeholders})') + params.extend(dashboard_acheteur_departement_code) + + if dashboard_titulaire_id: + clauses.append('"titulaire_id" LIKE ?') + params.append(f"%{dashboard_titulaire_id}%") + else: + if dashboard_titulaire_categorie: + clauses.append('"titulaire_categorie" = ?') + params.append(dashboard_titulaire_categorie) + if dashboard_titulaire_departement_code: + placeholders = ", ".join( + ["?"] * len(dashboard_titulaire_departement_code) + ) + clauses.append(f'"titulaire_departement_code" IN ({placeholders})') + params.extend(dashboard_titulaire_departement_code) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` +Expected: PASS (15 tests total). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py +rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py +rtk git commit -m "feat(observatoire): IN départements et skip conditionnel par ID (#72)" +``` + +--- + +## Task 5: Filtre liste (techniques, considérations sociales/environnementales) + +**Files:** + +- Modify: `tests/test_dashboard_filters_to_sql.py` +- Modify: `src/utils/table_sql.py` + +- [ ] **Step 1: Add failing tests** + +Append to `tests/test_dashboard_filters_to_sql.py`: + +```python +def test_marche_techniques_uses_list_has_any(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_techniques=["Enchère", "Accord-cadre"], + ) + assert where_sql == ( + 'YEAR("dateNotification") = ? ' + "AND list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])" + ) + assert params == [2025, ["Enchère", "Accord-cadre"]] + + +def test_considerations_sociales_uses_list_has_any(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_considerations_sociales=["Clause sociale"], + ) + assert where_sql == ( + 'YEAR("dateNotification") = ? ' + "AND list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])" + ) + assert params == [2025, ["Clause sociale"]] + + +def test_considerations_environnementales_uses_list_has_any(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_considerations_environnementales=["Clause env."], + ) + assert where_sql == ( + 'YEAR("dateNotification") = ? ' + "AND list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])" + ) + assert params == [2025, ["Clause env."]] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` +Expected: 3 new tests FAIL. + +- [ ] **Step 3: Extend the helper** + +Insert the following block in `dashboard_filters_to_sql`, **after** the `dashboard_marche_sous_traitance_declaree` block and **before** `return " AND ".join(clauses), params`: + +```python + if dashboard_marche_techniques: + clauses.append( + "list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])" + ) + params.append(list(dashboard_marche_techniques)) + + if dashboard_marche_considerations_sociales: + clauses.append( + "list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])" + ) + params.append(list(dashboard_marche_considerations_sociales)) + + if dashboard_marche_considerations_environnementales: + clauses.append( + "list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])" + ) + params.append(list(dashboard_marche_considerations_environnementales)) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` +Expected: PASS (18 tests total). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py +rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py +rtk git commit -m "feat(observatoire): filtres liste via list_has_any (#72)" +``` + +--- + +## Task 6: Filtres montant min/max (incluant 0) + +**Files:** + +- Modify: `tests/test_dashboard_filters_to_sql.py` +- Modify: `src/utils/table_sql.py` + +- [ ] **Step 1: Add failing tests** + +Append to `tests/test_dashboard_filters_to_sql.py`: + +```python +def test_montant_min_only(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_montant_min=1000, + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?' + assert params == [2025, 1000] + + +def test_montant_max_only(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_montant_max=500, + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "montant" <= ?' + assert params == [2025, 500] + + +def test_montant_zero_is_a_valid_lower_bound(): + # 0 est falsy mais reste un filtre valide (distinct de None) + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_montant_min=0, + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?' + assert params == [2025, 0] + + +def test_montant_min_and_max_combined(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_montant_min=100, + dashboard_montant_max=1000, + ) + assert where_sql == ( + 'YEAR("dateNotification") = ? AND "montant" >= ? AND "montant" <= ?' + ) + assert params == [2025, 100, 1000] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` +Expected: 4 new tests FAIL. + +- [ ] **Step 3: Extend the helper** + +Insert at the very end of `dashboard_filters_to_sql`, **just before** `return " AND ".join(clauses), params`: + +```python + if dashboard_montant_min is not None: + clauses.append('"montant" >= ?') + params.append(dashboard_montant_min) + + if dashboard_montant_max is not None: + clauses.append('"montant" <= ?') + params.append(dashboard_montant_max) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` +Expected: PASS (22 tests total). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py +rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py +rtk git commit -m "feat(observatoire): filtres montant min/max (#72)" +``` + +--- + +## Task 7: Réécriture de `prepare_dashboard_data` + +**Files:** + +- Modify: `src/utils/data.py` +- Modify: `tests/test_main.py` (supprimer `test_010_observatoire_montant_filter`) + +- [ ] **Step 1: Remove the obsolete Polars-based test** + +Delete the function `test_010_observatoire_montant_filter` from `tests/test_main.py` (lines ~218-256). La couverture du filtre montant est déjà assurée par les tests unitaires `test_montant_*` de la Task 6. + +- [ ] **Step 2: Rewrite `prepare_dashboard_data`** + +Replace the entire `prepare_dashboard_data` function in `src/utils/data.py` (lines ~86-194) with: + +```python +def prepare_dashboard_data(**filter_params) -> pl.DataFrame: + """Exécute la requête DuckDB filtrée pour le tableau de bord. + + Retourne une pl.DataFrame matérialisée uniquement pour le sous-ensemble + correspondant aux filtres. Les appelants qui ont besoin d'une LazyFrame + appellent `.lazy()` sur le résultat. + """ + from src.utils.table_sql import dashboard_filters_to_sql + + where_sql, params = dashboard_filters_to_sql(**filter_params) + return query_marches(where_sql=where_sql, params=params) +``` + +Update the import at the top of `src/utils/data.py`: + +```python +from src.db import get_cursor, query_marches, schema +``` + +Remove the now-unused import in `src/utils/data.py`: + +```python +from datetime import datetime, timedelta +``` + +(Si `datetime` n'est plus référencé dans `data.py` hors de `prepare_dashboard_data`, sinon garder.) + +**Vérification rapide à effectuer avant de supprimer `datetime`/`timedelta`** : + +```bash +rtk grep -n "datetime\|timedelta" src/utils/data.py +``` + +Si d'autres occurrences existent, conserver les imports. + +- [ ] **Step 3: Run the full test suite** + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py tests/test_main.py -v -k "not selenium and not dash_duo"` + +Ou, si filter n'est pas pratique : + +Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v` + +Expected: PASS (22 tests). + +- [ ] **Step 4: Commit** + +```bash +rtk pre-commit run --files src/utils/data.py tests/test_main.py +rtk git add src/utils/data.py tests/test_main.py +rtk git commit -m "refactor(observatoire): prepare_dashboard_data utilise DuckDB (#72)" +``` + +--- + +## Task 8: Adaptation des 3 appelants dans `observatoire.py` + +**Files:** + +- Modify: `src/pages/observatoire.py` + +- [ ] **Step 1: Update `_compute_dashboard_children`** + +Remplacer dans `src/pages/observatoire.py` (autour des lignes 660-670) : + +```python +@cache.memoize() +def _compute_dashboard_children(filter_params_normalized: tuple): + logger.debug("Cache miss — computing dashboard") + filter_params = { + k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized + } + + lff: pl.LazyFrame = query_marches().lazy() + lff = prepare_dashboard_data(lff=lff, **filter_params) + + dff = lff.collect(engine="streaming") +``` + +Par : + +```python +@cache.memoize() +def _compute_dashboard_children(filter_params_normalized: tuple): + logger.debug("Cache miss — computing dashboard") + filter_params = { + k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized + } + + dff = prepare_dashboard_data(**filter_params) + lff = dff.lazy() +``` + +Le reste de la fonction (à partir de `df_per_uid = ...`) est inchangé. + +- [ ] **Step 2: Update `download_observatoire`** + +Remplacer dans `src/pages/observatoire.py` (autour des lignes 789-800) : + +```python +def download_observatoire(_n_clicks, filter_params, hidden_columns): + lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {})) + + if hidden_columns: + lff = lff.drop(hidden_columns) + + def to_bytes(buffer): + lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP") + + date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") + return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx") +``` + +Par : + +```python +def download_observatoire(_n_clicks, filter_params, hidden_columns): + dff = prepare_dashboard_data(**(filter_params or {})) + + if hidden_columns: + dff = dff.drop(hidden_columns) + + def to_bytes(buffer): + dff.write_excel(buffer, worksheet="DECP") + + date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") + return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx") +``` + +- [ ] **Step 3: Update `populate_preview_table`** + +Remplacer dans `src/pages/observatoire.py` (autour des lignes 879-892) : + +```python + if not is_open: + return (no_update,) * 9 + + lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {})) + + return prepare_table_data( + lff, + data_timestamp, + filter_query, + page_current, + page_size, + sort_by, + "observatoire-preview", + ) +``` + +Par : + +```python + if not is_open: + return (no_update,) * 9 + + dff = prepare_dashboard_data(**(filter_params or {})) + + return prepare_table_data( + dff.lazy(), + data_timestamp, + filter_query, + page_current, + page_size, + sort_by, + "observatoire-preview", + ) +``` + +- [ ] **Step 4: Remove unused `query_marches` import** + +Dans `src/pages/observatoire.py`, ligne ~19 : + +```python +from src.db import query_marches, schema +``` + +Devient : + +```python +from src.db import schema +``` + +Vérifier avant de committer : + +```bash +rtk grep -n "query_marches" src/pages/observatoire.py +``` + +Expected: aucun résultat (ou uniquement des commentaires). + +- [ ] **Step 5: Smoke test** + +Démarrer l'app et naviguer sur `/observatoire`, vérifier à la main que : + +- Les cartes s'affichent. +- Un filtre année se propage. +- Un filtre acheteur par SIRET partiel fonctionne. +- Un filtre département (multi-valeur) fonctionne. +- Un filtre montant_min fonctionne. +- Le bouton « Télécharger au format Excel » génère un fichier non vide. +- Le bouton « Voir les données » ouvre l'offcanvas et peuple la table. + +Run: `python run.py` + +Expected: app démarre sans erreur ; les filtres se comportent comme avant. + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/pages/observatoire.py +rtk git add src/pages/observatoire.py +rtk git commit -m "refactor(observatoire): appelants utilisent la nouvelle signature (#72)" +``` + +--- + +## Task 9: Test d'intégration — `prepare_dashboard_data` sur `tests/test.parquet` + +**Files:** + +- Create: `tests/test_prepare_dashboard_data.py` + +- [ ] **Step 1: Write the failing test** + +Le but : vérifier que la fonction s'exécute réellement contre DuckDB, retourne une `pl.DataFrame`, et applique bien les filtres simples. `conftest.py` construit `tests/test.parquet` avec un jeu de données d'une ligne : acheteur_id `123`, acheteur_departement_code `75`, dateNotification `2025-01-01`, montant `10`. + +Create `tests/test_prepare_dashboard_data.py`: + +```python +import polars as pl + + +def test_returns_dataframe_with_year_filter(): + from src.utils.data import prepare_dashboard_data + + dff = prepare_dashboard_data(dashboard_year="2025") + assert isinstance(dff, pl.DataFrame) + assert dff.height == 1 + + +def test_year_mismatch_returns_empty(): + from src.utils.data import prepare_dashboard_data + + dff = prepare_dashboard_data(dashboard_year="2024") + assert isinstance(dff, pl.DataFrame) + assert dff.height == 0 + + +def test_acheteur_id_partial_match(): + from src.utils.data import prepare_dashboard_data + + dff = prepare_dashboard_data( + dashboard_year="2025", + dashboard_acheteur_id="12", + ) + assert dff.height == 1 + + +def test_departement_in_clause(): + from src.utils.data import prepare_dashboard_data + + dff = prepare_dashboard_data( + dashboard_year="2025", + dashboard_acheteur_departement_code=["75", "92"], + ) + assert dff.height == 1 + + +def test_montant_min_above_value_excludes_row(): + from src.utils.data import prepare_dashboard_data + + dff = prepare_dashboard_data( + dashboard_year="2025", + dashboard_montant_min=1000, + ) + assert dff.height == 0 +``` + +- [ ] **Step 2: Run the test** + +Run: `rtk pytest tests/test_prepare_dashboard_data.py -v` +Expected: PASS (5 tests). + +- [ ] **Step 3: Commit** + +```bash +rtk pre-commit run --files tests/test_prepare_dashboard_data.py +rtk git add tests/test_prepare_dashboard_data.py +rtk git commit -m "test(observatoire): intégration DuckDB pour prepare_dashboard_data (#72)" +``` + +--- + +## Task 10: Vérification finale + +**Files:** (aucune modification) + +- [ ] **Step 1: Run the full test suite** + +Run: `rtk pytest -v` +Expected: tous les tests unitaires passent. Les tests Selenium peuvent échouer si Chrome n'est pas disponible — ce n'est pas bloquant s'ils étaient déjà rouges avant. + +- [ ] **Step 2: Check for leftover references** + +Run: `rtk grep -rn "prepare_dashboard_data(lff" src/ tests/` +Expected: aucun résultat (plus d'appels avec l'ancienne signature). + +Run: `rtk grep -rn "query_marches().lazy()" src/` +Expected: aucun résultat (ou uniquement dans `src/utils/table.py:prepare_table_data` pour le fallback). + +- [ ] **Step 3: Confirm `datetime`/`timedelta` in data.py if needed** + +Run: `rtk grep -n "datetime\|timedelta" src/utils/data.py` + +Si aucune occurrence hors imports, vérifier que les imports inutiles ont bien été retirés dans Task 7. + +- [ ] **Step 4: Manual timing sanity check (optionnel)** + +Si possible, comparer informellement le temps de `_compute_dashboard_children` sur un filtre sélectif (ex. un département) avant/après. Pas de benchmark formel attendu. + +- [ ] **Step 5: Push (manuel, à l'initiative de l'utilisateur)** + +Conformément aux consignes projet, ne jamais `git push`. Laisser l'utilisateur pousser la branche `feature/72_observatoire_duckdb_filters` et ouvrir la PR. From 653999693c90e8d69b49b2391439b099e88ecfdd Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 23:36:59 +0200 Subject: [PATCH 016/151] feat(observatoire): squelette de dashboard_filters_to_sql (#72) --- src/utils/table_sql.py | 35 ++++++++++++++++++++++++++ tests/test_dashboard_filters_to_sql.py | 18 +++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 tests/test_dashboard_filters_to_sql.py diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index 67d7dcc..f67cff9 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta + import polars as pl from src.utils import logger @@ -100,3 +102,36 @@ def sort_by_to_sql(sort_by: list[dict] | None, schema: pl.Schema) -> str: fragments.append(f'"{col}" {direction.upper()} NULLS LAST') return ", ".join(fragments) + + +def dashboard_filters_to_sql( + dashboard_year=None, + dashboard_acheteur_id=None, + dashboard_acheteur_categorie=None, + dashboard_acheteur_departement_code=None, + dashboard_titulaire_id=None, + dashboard_titulaire_categorie=None, + dashboard_titulaire_departement_code=None, + dashboard_marche_type=None, + dashboard_marche_objet=None, + dashboard_marche_code_cpv=None, + dashboard_marche_considerations_sociales=None, + dashboard_marche_considerations_environnementales=None, + dashboard_marche_techniques=None, + dashboard_marche_innovant=None, + dashboard_marche_sous_traitance_declaree=None, + dashboard_montant_min=None, + dashboard_montant_max=None, +) -> tuple[str, list]: + """Traduit les filtres du tableau de bord en (where_clause, params) DuckDB.""" + clauses: list[str] = [] + params: list = [] + + if dashboard_year: + clauses.append('YEAR("dateNotification") = ?') + params.append(int(dashboard_year)) + else: + clauses.append('"dateNotification" > ?') + params.append(datetime.now() - timedelta(days=365)) + + return " AND ".join(clauses), params diff --git a/tests/test_dashboard_filters_to_sql.py b/tests/test_dashboard_filters_to_sql.py new file mode 100644 index 0000000..a9ca535 --- /dev/null +++ b/tests/test_dashboard_filters_to_sql.py @@ -0,0 +1,18 @@ +from datetime import datetime, timedelta + +from src.utils.table_sql import dashboard_filters_to_sql + + +def test_no_filters_uses_default_365_day_window(): + where_sql, params = dashboard_filters_to_sql() + assert where_sql == '"dateNotification" > ?' + assert len(params) == 1 + assert isinstance(params[0], datetime) + expected = datetime.now() - timedelta(days=365) + assert abs((params[0] - expected).total_seconds()) < 2 + + +def test_year_filter_overrides_default_window(): + where_sql, params = dashboard_filters_to_sql(dashboard_year="2025") + assert where_sql == 'YEAR("dateNotification") = ?' + assert params == [2025] From a382370767f7925e32988b4d829d126f0672466a Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 23:39:13 +0200 Subject: [PATCH 017/151] =?UTF-8?q?feat(observatoire):=20filtres=20d'?= =?UTF-8?q?=C3=A9galit=C3=A9=20simples=20dans=20dashboard=5Ffilters=5Fto?= =?UTF-8?q?=5Fsql=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/table_sql.py | 15 +++++++++++ tests/test_dashboard_filters_to_sql.py | 36 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index f67cff9..3aa826f 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -134,4 +134,19 @@ def dashboard_filters_to_sql( clauses.append('"dateNotification" > ?') params.append(datetime.now() - timedelta(days=365)) + if dashboard_marche_type: + clauses.append('"type" = ?') + params.append(dashboard_marche_type) + + if dashboard_marche_innovant and dashboard_marche_innovant != "all": + clauses.append('"marcheInnovant" = ?') + params.append(dashboard_marche_innovant) + + if ( + dashboard_marche_sous_traitance_declaree + and dashboard_marche_sous_traitance_declaree != "all" + ): + clauses.append('"sousTraitanceDeclaree" = ?') + params.append(dashboard_marche_sous_traitance_declaree) + return " AND ".join(clauses), params diff --git a/tests/test_dashboard_filters_to_sql.py b/tests/test_dashboard_filters_to_sql.py index a9ca535..5d542eb 100644 --- a/tests/test_dashboard_filters_to_sql.py +++ b/tests/test_dashboard_filters_to_sql.py @@ -16,3 +16,39 @@ def test_year_filter_overrides_default_window(): where_sql, params = dashboard_filters_to_sql(dashboard_year="2025") assert where_sql == 'YEAR("dateNotification") = ?' assert params == [2025] + + +def test_marche_type_equality(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_type="Marché", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "type" = ?' + assert params == [2025, "Marché"] + + +def test_innovant_value_all_is_skipped(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_innovant="all", + ) + assert where_sql == 'YEAR("dateNotification") = ?' + assert params == [2025] + + +def test_innovant_value_oui_adds_clause(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_innovant="oui", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "marcheInnovant" = ?' + assert params == [2025, "oui"] + + +def test_sous_traitance_value_non_adds_clause(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_sous_traitance_declaree="non", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "sousTraitanceDeclaree" = ?' + assert params == [2025, "non"] From e3a0fba4df9c0ecf766e993abc7b8bd33644a389 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 23:42:43 +0200 Subject: [PATCH 018/151] feat(observatoire): filtres LIKE/ILIKE dans dashboard_filters_to_sql (#72) --- src/utils/table_sql.py | 16 ++++++++++++ tests/test_dashboard_filters_to_sql.py | 36 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index 3aa826f..2cdf112 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -134,10 +134,26 @@ def dashboard_filters_to_sql( clauses.append('"dateNotification" > ?') params.append(datetime.now() - timedelta(days=365)) + if dashboard_acheteur_id: + clauses.append('"acheteur_id" LIKE ?') + params.append(f"%{dashboard_acheteur_id}%") + + if dashboard_titulaire_id: + clauses.append('"titulaire_id" LIKE ?') + params.append(f"%{dashboard_titulaire_id}%") + if dashboard_marche_type: clauses.append('"type" = ?') params.append(dashboard_marche_type) + if dashboard_marche_objet: + clauses.append('"objet" ILIKE ?') + params.append(f"%{dashboard_marche_objet}%") + + if dashboard_marche_code_cpv: + clauses.append('"codeCPV" LIKE ?') + params.append(f"{dashboard_marche_code_cpv}%") + if dashboard_marche_innovant and dashboard_marche_innovant != "all": clauses.append('"marcheInnovant" = ?') params.append(dashboard_marche_innovant) diff --git a/tests/test_dashboard_filters_to_sql.py b/tests/test_dashboard_filters_to_sql.py index 5d542eb..6e9d398 100644 --- a/tests/test_dashboard_filters_to_sql.py +++ b/tests/test_dashboard_filters_to_sql.py @@ -52,3 +52,39 @@ def test_sous_traitance_value_non_adds_clause(): ) assert where_sql == 'YEAR("dateNotification") = ? AND "sousTraitanceDeclaree" = ?' assert params == [2025, "non"] + + +def test_acheteur_id_uses_like_wildcards(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_acheteur_id="12345678900010", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?' + assert params == [2025, "%12345678900010%"] + + +def test_titulaire_id_uses_like_wildcards(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_titulaire_id="999", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?' + assert params == [2025, "%999%"] + + +def test_marche_objet_uses_case_insensitive_ilike(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_objet="travaux", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "objet" ILIKE ?' + assert params == [2025, "%travaux%"] + + +def test_code_cpv_uses_prefix_like(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_code_cpv="4521", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "codeCPV" LIKE ?' + assert params == [2025, "4521%"] From 74ae1fb00871602cc74e69b24e34ec14641605af Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 23:43:55 +0200 Subject: [PATCH 019/151] =?UTF-8?q?feat(observatoire):=20IN=20d=C3=A9parte?= =?UTF-8?q?ments=20et=20skip=20conditionnel=20par=20ID=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/table_sql.py | 16 ++++++++ tests/test_dashboard_filters_to_sql.py | 56 ++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index 2cdf112..4066393 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -137,10 +137,26 @@ def dashboard_filters_to_sql( if dashboard_acheteur_id: clauses.append('"acheteur_id" LIKE ?') params.append(f"%{dashboard_acheteur_id}%") + else: + if dashboard_acheteur_categorie: + clauses.append('"acheteur_categorie" = ?') + params.append(dashboard_acheteur_categorie) + if dashboard_acheteur_departement_code: + placeholders = ", ".join(["?"] * len(dashboard_acheteur_departement_code)) + clauses.append(f'"acheteur_departement_code" IN ({placeholders})') + params.extend(dashboard_acheteur_departement_code) if dashboard_titulaire_id: clauses.append('"titulaire_id" LIKE ?') params.append(f"%{dashboard_titulaire_id}%") + else: + if dashboard_titulaire_categorie: + clauses.append('"titulaire_categorie" = ?') + params.append(dashboard_titulaire_categorie) + if dashboard_titulaire_departement_code: + placeholders = ", ".join(["?"] * len(dashboard_titulaire_departement_code)) + clauses.append(f'"titulaire_departement_code" IN ({placeholders})') + params.extend(dashboard_titulaire_departement_code) if dashboard_marche_type: clauses.append('"type" = ?') diff --git a/tests/test_dashboard_filters_to_sql.py b/tests/test_dashboard_filters_to_sql.py index 6e9d398..dffd7ee 100644 --- a/tests/test_dashboard_filters_to_sql.py +++ b/tests/test_dashboard_filters_to_sql.py @@ -88,3 +88,59 @@ def test_code_cpv_uses_prefix_like(): ) assert where_sql == 'YEAR("dateNotification") = ? AND "codeCPV" LIKE ?' assert params == [2025, "4521%"] + + +def test_acheteur_departement_multiple_uses_in_clause(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_acheteur_departement_code=["75", "92", "93"], + ) + assert where_sql == ( + 'YEAR("dateNotification") = ? AND "acheteur_departement_code" IN (?, ?, ?)' + ) + assert params == [2025, "75", "92", "93"] + + +def test_acheteur_categorie_adds_clause(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_acheteur_categorie="Commune", + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_categorie" = ?' + assert params == [2025, "Commune"] + + +def test_titulaire_categorie_and_departement(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_titulaire_categorie="PME", + dashboard_titulaire_departement_code=["35"], + ) + assert where_sql == ( + 'YEAR("dateNotification") = ? ' + 'AND "titulaire_categorie" = ? ' + 'AND "titulaire_departement_code" IN (?)' + ) + assert params == [2025, "PME", "35"] + + +def test_acheteur_id_present_skips_categorie_and_departement(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_acheteur_id="123", + dashboard_acheteur_categorie="Commune", + dashboard_acheteur_departement_code=["75"], + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?' + assert params == [2025, "%123%"] + + +def test_titulaire_id_present_skips_categorie_and_departement(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_titulaire_id="999", + dashboard_titulaire_categorie="PME", + dashboard_titulaire_departement_code=["35"], + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?' + assert params == [2025, "%999%"] From 522c4677027009d9af8bf6734e3e7cf2fcc67cc3 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 23:49:32 +0200 Subject: [PATCH 020/151] feat(observatoire): filtres liste via list_has_any (#72) --- src/utils/table_sql.py | 16 ++++++++++++ tests/test_dashboard_filters_to_sql.py | 36 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index 4066393..bb59f18 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -181,4 +181,20 @@ def dashboard_filters_to_sql( clauses.append('"sousTraitanceDeclaree" = ?') params.append(dashboard_marche_sous_traitance_declaree) + if dashboard_marche_techniques: + clauses.append("list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])") + params.append(list(dashboard_marche_techniques)) + + if dashboard_marche_considerations_sociales: + clauses.append( + "list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])" + ) + params.append(list(dashboard_marche_considerations_sociales)) + + if dashboard_marche_considerations_environnementales: + clauses.append( + "list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])" + ) + params.append(list(dashboard_marche_considerations_environnementales)) + return " AND ".join(clauses), params diff --git a/tests/test_dashboard_filters_to_sql.py b/tests/test_dashboard_filters_to_sql.py index dffd7ee..d06d65b 100644 --- a/tests/test_dashboard_filters_to_sql.py +++ b/tests/test_dashboard_filters_to_sql.py @@ -144,3 +144,39 @@ def test_titulaire_id_present_skips_categorie_and_departement(): ) assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?' assert params == [2025, "%999%"] + + +def test_marche_techniques_uses_list_has_any(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_techniques=["Enchère", "Accord-cadre"], + ) + assert where_sql == ( + 'YEAR("dateNotification") = ? ' + "AND list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])" + ) + assert params == [2025, ["Enchère", "Accord-cadre"]] + + +def test_considerations_sociales_uses_list_has_any(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_considerations_sociales=["Clause sociale"], + ) + assert where_sql == ( + 'YEAR("dateNotification") = ? ' + "AND list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])" + ) + assert params == [2025, ["Clause sociale"]] + + +def test_considerations_environnementales_uses_list_has_any(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_marche_considerations_environnementales=["Clause env."], + ) + assert where_sql == ( + 'YEAR("dateNotification") = ? ' + "AND list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])" + ) + assert params == [2025, ["Clause env."]] From 6e670c97c9735435926f21867161fac69e87e152 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 23:53:43 +0200 Subject: [PATCH 021/151] feat(observatoire): filtres montant min/max (#72) --- src/utils/table_sql.py | 8 ++++++ tests/test_dashboard_filters_to_sql.py | 40 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index bb59f18..f670f80 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -197,4 +197,12 @@ def dashboard_filters_to_sql( ) params.append(list(dashboard_marche_considerations_environnementales)) + if dashboard_montant_min is not None: + clauses.append('"montant" >= ?') + params.append(dashboard_montant_min) + + if dashboard_montant_max is not None: + clauses.append('"montant" <= ?') + params.append(dashboard_montant_max) + return " AND ".join(clauses), params diff --git a/tests/test_dashboard_filters_to_sql.py b/tests/test_dashboard_filters_to_sql.py index d06d65b..017248b 100644 --- a/tests/test_dashboard_filters_to_sql.py +++ b/tests/test_dashboard_filters_to_sql.py @@ -180,3 +180,43 @@ def test_considerations_environnementales_uses_list_has_any(): "AND list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])" ) assert params == [2025, ["Clause env."]] + + +def test_montant_min_only(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_montant_min=1000, + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?' + assert params == [2025, 1000] + + +def test_montant_max_only(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_montant_max=500, + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "montant" <= ?' + assert params == [2025, 500] + + +def test_montant_zero_is_a_valid_lower_bound(): + # 0 est falsy mais reste un filtre valide (distinct de None) + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_montant_min=0, + ) + assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?' + assert params == [2025, 0] + + +def test_montant_min_and_max_combined(): + where_sql, params = dashboard_filters_to_sql( + dashboard_year="2025", + dashboard_montant_min=100, + dashboard_montant_max=1000, + ) + assert where_sql == ( + 'YEAR("dateNotification") = ? AND "montant" >= ? AND "montant" <= ?' + ) + assert params == [2025, 100, 1000] From 0777153c823089e153a23807676f40f67a013106 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 23:55:45 +0200 Subject: [PATCH 022/151] refactor(observatoire): prepare_dashboard_data utilise DuckDB (#72) --- src/utils/data.py | 119 ++++----------------------------------------- tests/test_main.py | 41 ---------------- 2 files changed, 10 insertions(+), 150 deletions(-) diff --git a/src/utils/data.py b/src/utils/data.py index e4817be..cd54f40 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -2,12 +2,11 @@ import json import logging import os from collections import OrderedDict -from datetime import datetime, timedelta import polars as pl from httpx import HTTPError, get -from src.db import get_cursor, schema +from src.db import get_cursor, query_marches, schema from src.utils import logger logging.getLogger("httpx").setLevel("WARNING") @@ -83,115 +82,17 @@ def get_data_schema() -> dict: return new_schema -def prepare_dashboard_data( - lff: pl.LazyFrame, - dashboard_year=None, - dashboard_acheteur_id=None, - dashboard_acheteur_categorie=None, - dashboard_acheteur_departement_code=None, - dashboard_titulaire_id=None, - dashboard_titulaire_categorie=None, - dashboard_titulaire_departement_code=None, - dashboard_marche_type=None, - dashboard_marche_objet=None, - dashboard_marche_code_cpv=None, - dashboard_marche_considerations_sociales=None, - dashboard_marche_considerations_environnementales=None, - dashboard_marche_techniques=None, - dashboard_marche_innovant=None, - dashboard_marche_sous_traitance_declaree=None, - dashboard_montant_min=None, - dashboard_montant_max=None, -) -> pl.LazyFrame: - if dashboard_year: - lff = lff.filter(pl.col("dateNotification").dt.year() == int(dashboard_year)) - else: - lff = lff.filter( - pl.col("dateNotification") > (datetime.now() - timedelta(days=365)) - ) +def prepare_dashboard_data(**filter_params) -> pl.DataFrame: + """Exécute la requête DuckDB filtrée pour le tableau de bord. - if dashboard_acheteur_id: - lff = lff.filter(pl.col("acheteur_id").str.contains(dashboard_acheteur_id)) - else: - if dashboard_acheteur_categorie: - lff = lff.filter( - pl.col("acheteur_categorie") == dashboard_acheteur_categorie - ) - if dashboard_acheteur_departement_code: - lff = lff.filter( - pl.col("acheteur_departement_code").is_in( - dashboard_acheteur_departement_code - ) - ) + Retourne une pl.DataFrame matérialisée uniquement pour le sous-ensemble + correspondant aux filtres. Les appelants qui ont besoin d'une LazyFrame + appellent `.lazy()` sur le résultat. + """ + from src.utils.table_sql import dashboard_filters_to_sql - if dashboard_titulaire_id: - lff = lff.filter(pl.col("titulaire_id").str.contains(dashboard_titulaire_id)) - else: - if dashboard_titulaire_categorie: - lff = lff.filter( - pl.col("titulaire_categorie") == dashboard_titulaire_categorie - ) - if dashboard_titulaire_departement_code: - lff = lff.filter( - pl.col("titulaire_departement_code").is_in( - dashboard_titulaire_departement_code - ) - ) - - if dashboard_marche_type: - lff = lff.filter(pl.col("type") == dashboard_marche_type) - - if dashboard_marche_objet: - lff = lff.filter(pl.col("objet").str.contains(f"(?i){dashboard_marche_objet}")) - - if dashboard_marche_code_cpv: - lff = lff.filter(pl.col("codeCPV").str.starts_with(dashboard_marche_code_cpv)) - - if dashboard_marche_innovant and dashboard_marche_innovant != "all": - lff = lff.filter(pl.col("marcheInnovant") == dashboard_marche_innovant) - - if ( - dashboard_marche_sous_traitance_declaree - and dashboard_marche_sous_traitance_declaree != "all" - ): - lff = lff.filter( - pl.col("sousTraitanceDeclaree") == dashboard_marche_sous_traitance_declaree - ) - - if dashboard_marche_techniques: - lff = lff.filter( - pl.col("techniques") - .str.split(", ") - .list.set_intersection(dashboard_marche_techniques) - .list.len() - > 0 - ) - - if dashboard_marche_considerations_sociales: - lff = lff.filter( - pl.col("considerationsSociales") - .str.split(", ") - .list.set_intersection(dashboard_marche_considerations_sociales) - .list.len() - > 0 - ) - - if dashboard_marche_considerations_environnementales: - lff = lff.filter( - pl.col("considerationsEnvironnementales") - .str.split(", ") - .list.set_intersection(dashboard_marche_considerations_environnementales) - .list.len() - > 0 - ) - - if dashboard_montant_min is not None: - lff = lff.filter(pl.col("montant") >= dashboard_montant_min) - - if dashboard_montant_max is not None: - lff = lff.filter(pl.col("montant") <= dashboard_montant_max) - - return lff + where_sql, params = dashboard_filters_to_sql(**filter_params) + return query_marches(where_sql=where_sql, params=params) def build_org_frame(org_type: str) -> pl.DataFrame: diff --git a/tests/test_main.py b/tests/test_main.py index 7f55a02..ae9b36c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -215,47 +215,6 @@ def test_008_search_to_observatoire(dash_duo: DashComposite): ) -def test_010_observatoire_montant_filter(): - import datetime - - from src.utils.data import prepare_dashboard_data - - data = pl.DataFrame( - { - "uid": ["1", "2", "3"], - "montant": [100.0, 500.0, 1000.0], - "dateNotification": [datetime.date(2025, 1, 1)] * 3, - } - ) - - def apply(min_val=None, max_val=None): - return prepare_dashboard_data( - data.lazy(), - dashboard_year="2025", - dashboard_acheteur_id=None, - dashboard_acheteur_categorie=None, - dashboard_acheteur_departement_code=None, - dashboard_titulaire_id=None, - dashboard_titulaire_categorie=None, - dashboard_titulaire_departement_code=None, - dashboard_marche_type=None, - dashboard_marche_objet=None, - dashboard_marche_code_cpv=None, - dashboard_marche_considerations_sociales=None, - dashboard_marche_considerations_environnementales=None, - dashboard_marche_techniques=None, - dashboard_marche_innovant=None, - dashboard_marche_sous_traitance_declaree=None, - dashboard_montant_min=min_val, - dashboard_montant_max=max_val, - ).collect() - - assert apply().height == 3 - assert apply(min_val=400).height == 2 # 500, 1000 - assert apply(max_val=500).height == 2 # 100, 500 - assert apply(min_val=200, max_val=600).height == 1 # 500 only - - def test_009_observatoire_filter_persistence(dash_duo: DashComposite): import time From c45d4e0ea19927c2b77aa603ede4a66a8488dbd2 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 22 Apr 2026 23:57:10 +0200 Subject: [PATCH 023/151] refactor(observatoire): appelants utilisent la nouvelle signature (#72) Co-Authored-By: Claude Sonnet 4.6 --- src/pages/observatoire.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/pages/observatoire.py b/src/pages/observatoire.py index 2391a2a..4fae0c7 100644 --- a/src/pages/observatoire.py +++ b/src/pages/observatoire.py @@ -16,7 +16,7 @@ from dash import ( register_page, ) -from src.db import query_marches, schema +from src.db import schema from src.figures import ( DataTable, get_barchart_sources, @@ -664,10 +664,8 @@ def _compute_dashboard_children(filter_params_normalized: tuple): k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized } - lff: pl.LazyFrame = query_marches().lazy() - lff = prepare_dashboard_data(lff=lff, **filter_params) - - dff = lff.collect(engine="streaming") + dff = prepare_dashboard_data(**filter_params) + lff = dff.lazy() df_per_uid = ( dff.select("uid", "montant").group_by("uid").agg(pl.col("montant").first()) @@ -788,13 +786,13 @@ def update_dashboard_cards(*filter_values): prevent_initial_call=True, ) def download_observatoire(_n_clicks, filter_params, hidden_columns): - lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {})) + dff = prepare_dashboard_data(**(filter_params or {})) if hidden_columns: - lff = lff.drop(hidden_columns) + dff = dff.drop(hidden_columns) def to_bytes(buffer): - lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP") + dff.write_excel(buffer, worksheet="DECP") date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx") @@ -879,10 +877,10 @@ def populate_preview_table( if not is_open: return (no_update,) * 9 - lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {})) + dff = prepare_dashboard_data(**(filter_params or {})) return prepare_table_data( - lff, + dff.lazy(), data_timestamp, filter_query, page_current, From a715140af08e42667f9ab6dd91b35d873117649d Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 23 Apr 2026 00:07:24 +0200 Subject: [PATCH 024/151] =?UTF-8?q?test(observatoire):=20int=C3=A9gration?= =?UTF-8?q?=20DuckDB=20pour=20prepare=5Fdashboard=5Fdata=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_prepare_dashboard_data.py | 47 ++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/test_prepare_dashboard_data.py diff --git a/tests/test_prepare_dashboard_data.py b/tests/test_prepare_dashboard_data.py new file mode 100644 index 0000000..369a299 --- /dev/null +++ b/tests/test_prepare_dashboard_data.py @@ -0,0 +1,47 @@ +import polars as pl + + +def test_returns_dataframe_with_year_filter(): + from src.utils.data import prepare_dashboard_data + + dff = prepare_dashboard_data(dashboard_year="2025") + assert isinstance(dff, pl.DataFrame) + assert dff.height == 1 + + +def test_year_mismatch_returns_empty(): + from src.utils.data import prepare_dashboard_data + + dff = prepare_dashboard_data(dashboard_year="2024") + assert isinstance(dff, pl.DataFrame) + assert dff.height == 0 + + +def test_acheteur_id_partial_match(): + from src.utils.data import prepare_dashboard_data + + dff = prepare_dashboard_data( + dashboard_year="2025", + dashboard_acheteur_id="12", + ) + assert dff.height == 1 + + +def test_departement_in_clause(): + from src.utils.data import prepare_dashboard_data + + dff = prepare_dashboard_data( + dashboard_year="2025", + dashboard_acheteur_departement_code=["75", "92"], + ) + assert dff.height == 1 + + +def test_montant_min_above_value_excludes_row(): + from src.utils.data import prepare_dashboard_data + + dff = prepare_dashboard_data( + dashboard_year="2025", + dashboard_montant_min=1000, + ) + assert dff.height == 0 From fc4d965b20a2c519fbb7803269af3ee04d0a9f89 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 23 Apr 2026 12:33:30 +0200 Subject: [PATCH 025/151] =?UTF-8?q?Spinner=20de=20chargemetn=20sur=20la=20?= =?UTF-8?q?pr=C3=A9viusalisation=20des=20donn=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/observatoire.py | 31 +++++++++++------- tests/test_prepare_dashboard_data.py | 47 ---------------------------- 2 files changed, 20 insertions(+), 58 deletions(-) delete mode 100644 tests/test_prepare_dashboard_data.py diff --git a/src/pages/observatoire.py b/src/pages/observatoire.py index 4fae0c7..e58c9ba 100644 --- a/src/pages/observatoire.py +++ b/src/pages/observatoire.py @@ -508,17 +508,26 @@ Alors, on fait comment ? size="xl", ), # DataTable - html.Div( - className="marches_table", - children=DataTable( - dtid="observatoire-preview-table", - page_size=5, - page_action="custom", - sort_action="custom", - filter_action="custom", - hidden_columns=[], - columns=[{"id": col, "name": col} for col in OBSERVATOIRE_COLUMNS], - ), + dcc.Loading( + overlay_style={"visibility": "visible", "filter": "blur(2px)"}, + id="loading-statistques", + type="default", + children=[ + html.Div( + className="marches_table", + children=DataTable( + dtid="observatoire-preview-table", + page_size=5, + page_action="custom", + sort_action="custom", + filter_action="custom", + hidden_columns=[], + columns=[ + {"id": col, "name": col} for col in OBSERVATOIRE_COLUMNS + ], + ), + ) + ], ), ], ), diff --git a/tests/test_prepare_dashboard_data.py b/tests/test_prepare_dashboard_data.py deleted file mode 100644 index 369a299..0000000 --- a/tests/test_prepare_dashboard_data.py +++ /dev/null @@ -1,47 +0,0 @@ -import polars as pl - - -def test_returns_dataframe_with_year_filter(): - from src.utils.data import prepare_dashboard_data - - dff = prepare_dashboard_data(dashboard_year="2025") - assert isinstance(dff, pl.DataFrame) - assert dff.height == 1 - - -def test_year_mismatch_returns_empty(): - from src.utils.data import prepare_dashboard_data - - dff = prepare_dashboard_data(dashboard_year="2024") - assert isinstance(dff, pl.DataFrame) - assert dff.height == 0 - - -def test_acheteur_id_partial_match(): - from src.utils.data import prepare_dashboard_data - - dff = prepare_dashboard_data( - dashboard_year="2025", - dashboard_acheteur_id="12", - ) - assert dff.height == 1 - - -def test_departement_in_clause(): - from src.utils.data import prepare_dashboard_data - - dff = prepare_dashboard_data( - dashboard_year="2025", - dashboard_acheteur_departement_code=["75", "92"], - ) - assert dff.height == 1 - - -def test_montant_min_above_value_excludes_row(): - from src.utils.data import prepare_dashboard_data - - dff = prepare_dashboard_data( - dashboard_year="2025", - dashboard_montant_min=1000, - ) - assert dff.height == 0 From dadbb0aeffb0deae5bacad9644965851e1e5968b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 24 Apr 2026 11:38:01 +0200 Subject: [PATCH 026/151] =?UTF-8?q?Possibilit=C3=A9=20de=20chercher=20soit?= =?UTF-8?q?=20des=20mots=20pr=C3=A9sents,=20soit=20une=20suite=20de=20mot?= =?UTF-8?q?=20pr=C3=A9cise=20#42?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/db.py | 5 +++++ src/utils/filtering.py | 0 src/utils/table_sql.py | 41 ++++++++++++++++++++++++++++------------- 3 files changed, 33 insertions(+), 13 deletions(-) delete mode 100644 src/utils/filtering.py diff --git a/src/db.py b/src/db.py index f9ffd39..a0e1978 100644 --- a/src/db.py +++ b/src/db.py @@ -155,12 +155,16 @@ def query_marches( sql += f" LIMIT {int(limit)}" if offset is not None: sql += f" OFFSET {int(offset)}" + + logger.debug("query_marches: " + sql.replace("?", "{}").format(*params)) + return get_cursor().execute(sql, list(params)).pl() def count_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int: """Retourne le nombre de lignes correspondant à where_sql.""" sql = f"SELECT COUNT(*) FROM decp WHERE {where_sql}" + logger.debug("count_marches: " + sql.replace("?", "{}").format(*params)) result = get_cursor().execute(sql, list(params)).fetchone() return int(result[0]) if result else 0 @@ -168,5 +172,6 @@ def count_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int: def count_unique_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int: """Retourne le nombre de uid distincts correspondant à where_sql.""" sql = f"SELECT COUNT(DISTINCT uid) FROM decp WHERE {where_sql}" + logger.debug("count_unique_marches: " + sql.replace("?", "{}").format(*params)) result = get_cursor().execute(sql, list(params)).fetchone() return int(result[0]) if result else 0 diff --git a/src/utils/filtering.py b/src/utils/filtering.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index f670f80..516d6dd 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -57,17 +57,11 @@ def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list value = raw_value.strip('"') if operator == "contains": - if value.endswith("*") and not value.startswith("*"): - like = value[:-1] + "%" - elif value.startswith("*") and not value.endswith("*"): - like = "%" + value[1:] - else: - like = "%" + value + "%" - target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col - clauses.append( - f"{quoted_col} IS NOT NULL AND {target} <> '' AND {target} ILIKE ?" - ) - params.append(like) + where_clause, param_list = tokenize_text_filter(col_name, value) + clauses.append(where_clause) + params.extend(param_list) + logger.debug(params) + elif operator in (">", "<"): target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col clauses.append(f"{quoted_col} IS NOT NULL AND {target} {operator} ?") @@ -163,8 +157,9 @@ def dashboard_filters_to_sql( params.append(dashboard_marche_type) if dashboard_marche_objet: - clauses.append('"objet" ILIKE ?') - params.append(f"%{dashboard_marche_objet}%") + where_clause, param_list = tokenize_text_filter("objet", dashboard_marche_objet) + clauses.append(where_clause) + params.extend(param_list) if dashboard_marche_code_cpv: clauses.append('"codeCPV" LIKE ?') @@ -206,3 +201,23 @@ def dashboard_filters_to_sql( params.append(dashboard_montant_max) return " AND ".join(clauses), params + + +def tokenize_text_filter(column: str, text: str) -> tuple[str, list]: + terms = text.split() + + conditions = [] + params = [] + + for term in terms: + conditions.append(f'"{column}" ILIKE ?') + + if term.startswith("*") or term.endswith("*"): + params.append(term.replace("*", "%")) + if "+" in term: + params.append(f"%{term.replace('+', ' ')}%") + else: + params.append(f"%{term}%") + + where_clause = " AND ".join(conditions) + return where_clause, params From 93777cce6d8b06e6ab0b17b652392cbb3cb4a530 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 24 Apr 2026 11:50:36 +0200 Subject: [PATCH 027/151] Changelog v2.7.5 --- CHANGELOG.md | 6 ++++++ README.md | 1 - pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7cfd94..5b1e727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +##### 2.7.5 (24 avril 2026) + +- Amélioration des permormances de l'observatoire +- Possibilité dans observatoire (champ objet) et tableau (tous champs texte) de soit chercher des mots présents, soit une suite de mot précise (voir mode d'emploi dans Tableau) +- Ajout d'une animation pendant le chargement de la prévisualisation des données de l'observatoire + ##### 2.7.4 (22 avril 2026) - Utilisation élargie de DuckDB au détriment de Polars => bien meilleure perf ([#72](https://github.com/ColinMaudry/decp.info/issues/72) diff --git a/README.md b/README.md index 4735a79..0378501 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # decp.info -> v2.7.4 > Outil d'exploration et de téléchargement des données essentielles de la commande publique. => [decp.info](https://decp.info) diff --git a/pyproject.toml b/pyproject.toml index e6cb9a1..ee90897 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "decp.info" description = "Interface d'exploration et d'analyse des marchés publics français." -version = "2.7.4" +version = "2.7.5" requires-python = ">= 3.10" authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }] dependencies = [ From 10f24dec30390fd94c9ad80d05dbf0a6671a3615 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 24 Apr 2026 12:08:48 +0200 Subject: [PATCH 028/151] Petites corrections --- src/utils/table_sql.py | 5 +++-- tests/test_dashboard_filters_to_sql.py | 5 ++++- tests/test_table_sql.py | 10 ---------- uv.lock | 2 +- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index 516d6dd..8e8badd 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -206,7 +206,8 @@ def dashboard_filters_to_sql( def tokenize_text_filter(column: str, text: str) -> tuple[str, list]: terms = text.split() - conditions = [] + conditions = [f'"{column}" IS NOT NULL', f"\"{column}\" <> ''"] + params = [] for term in terms: @@ -214,7 +215,7 @@ def tokenize_text_filter(column: str, text: str) -> tuple[str, list]: if term.startswith("*") or term.endswith("*"): params.append(term.replace("*", "%")) - if "+" in term: + elif "+" in term: params.append(f"%{term.replace('+', ' ')}%") else: params.append(f"%{term}%") diff --git a/tests/test_dashboard_filters_to_sql.py b/tests/test_dashboard_filters_to_sql.py index 017248b..043ac24 100644 --- a/tests/test_dashboard_filters_to_sql.py +++ b/tests/test_dashboard_filters_to_sql.py @@ -77,7 +77,10 @@ def test_marche_objet_uses_case_insensitive_ilike(): dashboard_year="2025", dashboard_marche_objet="travaux", ) - assert where_sql == 'YEAR("dateNotification") = ? AND "objet" ILIKE ?' + assert ( + where_sql + == 'YEAR("dateNotification") = ? AND "objet" IS NOT NULL AND "objet" <> \'\' AND "objet" ILIKE ?' + ) assert params == [2025, "%travaux%"] diff --git a/tests/test_table_sql.py b/tests/test_table_sql.py index 8675e19..75f6ee6 100644 --- a/tests/test_table_sql.py +++ b/tests/test_table_sql.py @@ -106,16 +106,6 @@ def test_unknown_column_is_skipped(): assert params == [] -def test_escapes_identifier_with_quotes_not_concatenation(): - from src.utils.table_sql import filter_query_to_sql - - where, params = filter_query_to_sql( - "{objet} icontains '; DROP TABLE decp; --", SCHEMA - ) - assert "DROP TABLE" not in where - assert any("DROP TABLE" in str(p) for p in params) - - def test_sort_by_empty(): from src.utils.table_sql import sort_by_to_sql diff --git a/uv.lock b/uv.lock index ecfad9e..783477a 100644 --- a/uv.lock +++ b/uv.lock @@ -760,7 +760,7 @@ wheels = [ [[package]] name = "decp-info" -version = "2.7.4" +version = "2.7.5" source = { virtual = "." } dependencies = [ { name = "dash", extra = ["compress"] }, From d8ee6e5b3751b1cec44fd3963cc8c79c482aaf60 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 24 Apr 2026 12:31:29 +0200 Subject: [PATCH 029/151] =?UTF-8?q?M=C3=A0j=20du=20mode=20d'emploi=20de=20?= =?UTF-8?q?Tableau=20#42?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/tableau.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 63d9927..a113d4b 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -163,18 +163,19 @@ layout = [ Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`. - - Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché et n'est pas sensible à la casse (majuscules/minuscules). - - Exemple : `rennes` retourne "RENNES METROPOLE". + - Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché, n'est pas sensible à la casse (majuscules/minuscules) et est sensbible à l'accentuation. + - `rennes` => le texte contient "rennes" + - `metro* *pole` => le texte contient un mot qui commence par "metro" et un mot qui finit par "pole" + - `metropole rennes` => le texte contient les mots "metropole" et "rennes", n'importe où dans le texte + - `metropole+rennes` => le texte contient "metropole rennes", collé et dans cet ordre + - `metropole+rennes travaux distri*` => le texte contient "metropole rennes", "travaux" et un mot qui commence par "distri" - Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances` - Champs numériques (Durée en mois, Montant, ...) : vous pouvez... - soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12 - soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres. - - Champs date (Date de notification, ...) : vous pouvez également utiliser **>** ou **<**. Exemples : + - Champs date (Date de notification, ...) : - `< 2024-01-31` pour "avant le 31 janvier 2024" - - `2024` pour "en 2024", `> 2022` pour "à partir de 2022". - - Pour les champs textuels et les champs dates : - - pour chercher du texte qui **commence par** votre texte, entrez `texte*`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`) ou les marchés sur une année en particulier (`2024*`) - - pour chercher du texte qui **finit par** votre texte, entrez `*texte` + - `2024` pour "en 2024", `> 2022` pour "à partir de 2022" Vous pouvez filtrer plusieurs colonnes à la fois. From a6049b324438eea0e9f856afa13513022a966337 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 24 Apr 2026 13:35:49 +0200 Subject: [PATCH 030/151] Bumped version checkout --- .github/workflows/deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index dbd7476..fd0c2e9 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -20,7 +20,7 @@ jobs: environment: ${{ github.ref_name }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up SSH key run: | From 1839928e697ece6377b8f5d06487de3fe975b448 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 24 Apr 2026 13:36:15 +0200 Subject: [PATCH 031/151] =?UTF-8?q?R=C3=A9duction=20des=20petites=20erreur?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/figures.py | 8 +++++++- src/pages/marche.py | 2 +- src/utils/data.py | 2 +- src/utils/seo.py | 2 ++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/figures.py b/src/figures.py index b89e395..16a36eb 100644 --- a/src/figures.py +++ b/src/figures.py @@ -11,8 +11,10 @@ import plotly.graph_objects as go import polars as pl from dash import dash_table, dcc, html from dash_extensions.javascript import Namespace +from polars.exceptions import ColumnNotFoundError from src.db import schema +from src.utils import logger from src.utils.data import DATA_SCHEMA, DEPARTEMENTS_GEOJSON from src.utils.table import add_links, format_number, setup_table_columns @@ -833,7 +835,11 @@ def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = lff = lff.cast(pl.String) lff = lff.fill_null("") - dff: pl.DataFrame = lff.collect(engine="streaming") + try: + dff: pl.DataFrame = lff.collect(engine="streaming") + except ColumnNotFoundError: + logger.warning(f"get_top_org_table: column not found. {lff.collect_schema()}") + return html.Div() if dff.height == 0: return html.Div() diff --git a/src/pages/marche.py b/src/pages/marche.py index 3709377..91786d2 100644 --- a/src/pages/marche.py +++ b/src/pages/marche.py @@ -109,7 +109,7 @@ def update_marche_info(marche, titulaires): column_object = DATA_SCHEMA.get(col) column_name = column_object.get("title") if column_object else col - if marche[col]: + if col in marche: if col == "acheteur_nom": value = html.A( href=f"/acheteurs/{marche['acheteur_id']}", diff --git a/src/utils/data.py b/src/utils/data.py index cd54f40..eb9c170 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -12,7 +12,7 @@ from src.utils import logger logging.getLogger("httpx").setLevel("WARNING") -def get_annuaire_data(siret: str) -> dict: +def get_annuaire_data(siret: str) -> dict | None: url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}" try: response = get(url).raise_for_status() diff --git a/src/utils/seo.py b/src/utils/seo.py index f21439f..e383624 100644 --- a/src/utils/seo.py +++ b/src/utils/seo.py @@ -7,6 +7,8 @@ def make_org_jsonld(org_id, org_type, org_name=None, type_org_id="SIRET") -> dic address = None if type_org_id.lower() == "siret" and len(org_id) == 14: annuaire_data = get_annuaire_data(org_id) + if not annuaire_data: + return {} annuaire_address = annuaire_data["matching_etablissements"][0] code_postal = annuaire_address["code_postal"] commune = annuaire_address["libelle_commune"] From bd6a4ff2660f2a1503b6f0de279ea091d41abbe3 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 28 Apr 2026 10:40:50 +0200 Subject: [PATCH 032/151] =?UTF-8?q?Tentative=20de=20r=C3=A9tablissement=20?= =?UTF-8?q?de=20la=20carte=20sur=20acheteur/titulaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-04-28-organization-maps.md | 108 ++++++++++++++++++ pyproject.toml | 3 +- run.py | 4 +- src/figures.py | 86 ++++++++++---- src/pages/acheteur.py | 9 +- src/pages/marche.py | 2 +- src/pages/titulaire.py | 9 +- src/utils/table.py | 2 + uv.lock | 15 +++ 9 files changed, 210 insertions(+), 28 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-28-organization-maps.md diff --git a/docs/superpowers/plans/2026-04-28-organization-maps.md b/docs/superpowers/plans/2026-04-28-organization-maps.md new file mode 100644 index 0000000..4e9e54a --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-organization-maps.md @@ -0,0 +1,108 @@ +# Plan: Ajouter des cartes de localisation aux pages acheteur et titulaire + +## Date: 2026-04-28 + +## Statut: Approuvé + +## Objectif: Ajouter des cartes interactives montrant la localisation des organisations sur les pages acheteur et titulaire + +## Contexte + +- Les pages acheteur et titulaire ont déjà des placeholders pour les cartes (`acheteur_map` et `titulaire_map`) +- La fonction `point_on_map()` existe déjà dans `src/figures.py` mais utilise un centrage fixe sur la France +- Les données de localisation proviennent de l'API Annuaire des Entreprises +- Les codes départementaux sont disponibles et plus fiables que les coordonnées pour la détection de région + +## Exigences + +### 1. Carte interactive + +- **Localisation**: Colonne de droite dans la section d'informations sur l'organisation +- **Taille**: 400px de largeur × 300px de hauteur (fixe) +- **Contenu**: Carte centrée sur la France ou le département d'outre-mer approprié avec un point rouge à l'emplacement de l'organisation +- **Niveau de zoom**: Approprié pour montrer l'Hexagone ou le département d'outre-mer spécifique +- **Style**: Fond de carte clair avec point rouge visible +- **Interactivité**: Carte zoomable et déplaçable (pas de configuration statique) + +### 2. Sources de données + +- Utiliser les colonnes `acheteur_latitude` et `acheteur_longitude` pour les pages acheteur +- Utiliser les colonnes `titulaire_latitude` et `titulaire_longitude` pour les pages titulaire +- Utiliser les codes départementaux (`acheteur_departement_code`, `titulaire_departement_code`) pour la détection de région +- Solution de repli: Si les coordonnées ou codes départementaux sont manquants ou invalides, afficher une div vide + +### 3. Détection de région + +- **Départements métropolitains**: Codes à 2 caractères (ex: "75" pour Paris) → Carte Hexagone +- **Départements d'outre-mer**: + - "971" → Guadeloupe + - "972" → Martinique + - "973" → Guyane + - "974" → La Réunion + - "976" → Mayotte +- **Code département manquant**: Retourner une div vide (pas de détection basée sur les coordonnées) + +### 4. Gestion des erreurs + +- Coordonnées invalides → div vide +- Code département manquant → div vide +- Échec de l'API Annuaire → div vide (comportement existant) +- Format de code département invalide → div vide + +## Implémentation + +### Fichiers à modifier + +#### 1. `src/figures.py` - Améliorer la fonction `point_on_map()` + +**Ligne 178-209**: Remplacer la fonction existante par une version améliorée avec: + +- Détection de région basée sur les codes départementaux +- Configuration de carte interactive (zoomable) +- Point plus grand (size=15) +- Commentaires en français + +#### 2. `src/pages/acheteur.py` - Mettre à jour le callback + +**Ligne 249-297**: Modifier `update_acheteur_infos()` pour: + +- Extraire le code département du code postal +- Passer le code département à `point_on_map()` +- Ajouter des commentaires en français + +#### 3. `src/pages/titulaire.py` - Mettre à jour le callback + +**Ligne 259-297**: Modifier `update_titulaire_infos()` pour: + +- Extraire le code département du code postal +- Passer le code département à `point_on_map()` +- Ajouter des commentaires en français + +## Plan de Test + +### Cas de test prioritaires + +1. **Organisation métropolitaine**: Code département "75" (Paris) → Carte Hexagone +2. **Organisation à La Réunion**: Code département "974" → Carte centrée sur La Réunion +3. **Code département manquant**: Retourne une div vide +4. **Coordonnées invalides**: Retourne une div vide +5. **Interactivité**: Vérifier zoom et déplacement + +### Critères d'acceptation + +- [ ] Cartes fonctionnelles avec codes départementaux valides +- [ ] Div vide pour codes manquants/invalides +- [ ] Cartes correctement centrées et zoomées +- [ ] Interactivité (zoom et déplacement) +- [ ] Point de localisation visible (size=15) + +## Approbation + +Plan approuvé avec spécifications: + +- Réutiliser et améliorer `point_on_map` +- Retourner div vide sans code département +- Point légèrement plus grand +- Cartes zoomables +- Utiliser codes départementaux pour détection de région +- Commentaires en français diff --git a/pyproject.toml b/pyproject.toml index ee90897..873a560 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,13 +14,14 @@ dependencies = [ "xlsxwriter", "plotly[express]", "httpx", - "pandas", # utilisé pour la création de certains graphiques + "pandas", # utilisé pour la création de certains graphiques "unidecode", "dash-leaflet", "dash-extensions", "duckdb", "flask-caching", "pyarrow>=23.0.1", + "flask-cors>=6.0.2", ] [dependency-groups] diff --git a/run.py b/run.py index 215b786..a3e312a 100644 --- a/run.py +++ b/run.py @@ -1,7 +1,9 @@ +from flask_cors import CORS + from src.app import app # To use `gunicorn run:server` (prod) -server = app.server +server = CORS(app.server) # To use `python run.py` (dev) if __name__ == "__main__": diff --git a/src/figures.py b/src/figures.py index 16a36eb..9a2807b 100644 --- a/src/figures.py +++ b/src/figures.py @@ -175,38 +175,78 @@ def get_sources_tables(source_path) -> html.Div: return html.Div(children=datatable) -def point_on_map(lat, lon): - lat = float(lat) - lon = float(lon) +def point_on_map(lat, lon, departement_code=None): + """Fonction améliorée utilisant les codes départementaux pour la détection de région. - # Create a scatter mapbox or choropleth map - fig = px.scatter_map( - lat=[lat], lon=[lon], height=300, width=400, color=[1], size=[1] + Args: + lat: Coordonnée de latitude + lon: Coordonnée de longitude + departement_code: Code du département (ex: '75', '971', etc.) + + Returns: + html.Div contenant la carte, ou div vide si invalide + """ + # Validation des coordonnées + try: + lat = float(lat) + lon = float(lon) + except (TypeError, ValueError): + return html.Div() # Div vide pour les coordonnées invalides + + # Vérification que les coordonnées sont valides + if not (-90 <= lat <= 90) or not (-180 <= lon <= 180): + return html.Div() + + # Si aucun code département n'est fourni, retourner une div vide + if not departement_code: + return html.Div() + + # Détermination de la région en utilisant le code département + # Logique identique à get_geographic_maps + if departement_code in ["971", "972", "973", "974", "976"]: + region_key = departement_code # Département d'outre-mer + elif len(departement_code) == 2: # Département métropolitain + region_key = "Hexagone" + else: + return html.Div() # Format de code département invalide + + # Paramètres de carte par région (réutilisés de get_geographic_maps) + regions = { + "Hexagone": {"center": [46.6, 2.2], "zoom": 5}, + "971": {"center": [16.23, -61.55], "zoom": 9}, # Guadeloupe + "972": {"center": [14.64, -61.02], "zoom": 10}, # Martinique + "973": {"center": [3.93, -53.12], "zoom": 7}, # Guyane + "974": {"center": [-21.11, 55.53], "zoom": 9}, # La Réunion + "976": {"center": [-12.82, 45.16], "zoom": 10}, # Mayotte + } + + settings = regions.get(region_key, regions["Hexagone"]) + + # Création de la carte avec un point plus grand + fig = px.scatter_mapbox( + lat=[lat], + lon=[lon], + height=300, + width=400, + color=[1], + size=[15], # Point plus grand comme demandé + zoom=settings["zoom"], ) - fig.update_coloraxes(showscale=False) - - # Set map style (you can use 'open-street-map', 'carto-positron', etc.) + # Configuration de la carte (interactive - zoomable) fig.update_layout( - mapbox_style="light", # Light, clean background + mapbox_style="light", # Fond de carte clair margin={"r": 0, "t": 0, "l": 0, "b": 0}, + # Note: pas de uirevision='static' pour permettre l'interactivité ) - # Optionally, center the map on France - fig.update_geos( - center=dict(lat=46.603354, lon=1.888334), # Center of France - lataxis_range=[41, 51.5], # Latitude range for France - lonaxis_range=[-5, 10], # Longitude range for France + # Centrage et zoom appropriés + fig.update_layout( + mapbox_center={"lat": settings["center"][0], "lon": settings["center"][1]}, + mapbox_zoom=settings["zoom"], ) - # But scatter_mapbox doesn't use geos, so better to control via zoom/center manually - # Let's reset and use proper centering in scatter_mapbox instead: - - fig.update_layout(map_center={"lat": 46.6, "lon": 1.89}, map_zoom=4) - - graph = dcc.Graph(id="map", figure=fig) - graph = html.Div(style={"width": "400px"}) - return graph + return html.Div(dcc.Graph(figure=fig), style={"width": "400px"}) class DataTable(dash_table.DataTable): diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index e0e708a..23bf203 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -257,8 +257,15 @@ def update_acheteur_infos(url): if data_etablissement: data_etablissement = data_etablissement[0] + # Extraction du code département à partir du code postal + code_postal = data_etablissement.get("code_postal", "") + departement_code = code_postal[:2] if code_postal else None + + # Création de la carte avec le code département pour un centrage approprié acheteur_map = point_on_map( - data_etablissement["latitude"], data_etablissement["longitude"] + data_etablissement["latitude"], + data_etablissement["longitude"], + departement_code, ) code_departement, nom_departement, nom_region = get_departement_region( data_etablissement["code_postal"] diff --git a/src/pages/marche.py b/src/pages/marche.py index 91786d2..9587b56 100644 --- a/src/pages/marche.py +++ b/src/pages/marche.py @@ -243,7 +243,7 @@ def get_marche_jsonld(marche, titulaires) -> str: titulaire.get("titulaire_id"), org_name=titulaire.get("titulaire_nom"), org_type="titulaire", - type_org_id=titulaire.get("titulaire_typeIdentifiant"), + type_org_id=titulaire.get("titulaire_typeIdentifiant", "SIRET"), ), "orderedItem": { "@type": type_order, diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 2a1005c..3edb8ba 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -263,8 +263,15 @@ def update_titulaire_infos(url): if data_etablissement: data_etablissement = data_etablissement[0] + # Extraction du code département à partir du code postal + code_postal = data_etablissement.get("code_postal", "") + departement_code = code_postal[:2] if code_postal else None + + # Création de la carte avec le code département pour un centrage approprié titulaire_map = point_on_map( - data_etablissement["latitude"], data_etablissement["longitude"] + data_etablissement["latitude"], + data_etablissement["longitude"], + departement_code, ) code_departement, nom_departement, nom_region = get_departement_region( data_etablissement["code_postal"] diff --git a/src/utils/table.py b/src/utils/table.py index cb4108e..ef5221c 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -154,6 +154,8 @@ def normalize_sort_by(sort_by) -> tuple: def format_number(number) -> str: + if not number: + return "" number = "{:,}".format(number).replace(",", " ") return number diff --git a/uv.lock b/uv.lock index 783477a..db47a6d 100644 --- a/uv.lock +++ b/uv.lock @@ -771,6 +771,7 @@ dependencies = [ { name = "dash-leaflet", version = "1.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "duckdb" }, { name = "flask-caching" }, + { name = "flask-cors" }, { name = "gunicorn" }, { name = "httpx" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -803,6 +804,7 @@ requires-dist = [ { name = "dash-leaflet" }, { name = "duckdb" }, { name = "flask-caching" }, + { name = "flask-cors", specifier = ">=6.0.2" }, { name = "gunicorn" }, { name = "httpx" }, { name = "pandas" }, @@ -978,6 +980,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/0f/fe51e0b2301bbd429af44273a923ff92127b18d13abba5ae5a1d60e8e497/flask_compress-1.24-py3-none-any.whl", hash = "sha256:1e63668eb6e3242bd4f6ad98825a924e3984409be90c125477893d586007d00c", size = 11033 }, ] +[[package]] +name = "flask-cors" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257 }, +] + [[package]] name = "geobuf" version = "2.0.1" From 1a5f049b1a62de19f113ff77e2ce6f4745fb7829 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 28 Apr 2026 11:23:02 +0200 Subject: [PATCH 033/151] =?UTF-8?q?Map=20fonctionne=20mais=20lf=20cass?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/figures.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/figures.py b/src/figures.py index 9a2807b..f92e07f 100644 --- a/src/figures.py +++ b/src/figures.py @@ -222,8 +222,8 @@ def point_on_map(lat, lon, departement_code=None): settings = regions.get(region_key, regions["Hexagone"]) - # Création de la carte avec un point plus grand - fig = px.scatter_mapbox( + # Création de la carte + fig = px.scatter_map( lat=[lat], lon=[lon], height=300, @@ -235,15 +235,11 @@ def point_on_map(lat, lon, departement_code=None): # Configuration de la carte (interactive - zoomable) fig.update_layout( - mapbox_style="light", # Fond de carte clair + map_style="light", # Fond de carte clair margin={"r": 0, "t": 0, "l": 0, "b": 0}, - # Note: pas de uirevision='static' pour permettre l'interactivité - ) - - # Centrage et zoom appropriés - fig.update_layout( mapbox_center={"lat": settings["center"][0], "lon": settings["center"][1]}, mapbox_zoom=settings["zoom"], + coloraxis_showscale=False, ) return html.Div(dcc.Graph(figure=fig), style={"width": "400px"}) From 25746b4869a0633b8cb3dc85dd52033464d97d1d Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 28 Apr 2026 13:34:33 +0200 Subject: [PATCH 034/151] =?UTF-8?q?Am=C3=A9lioration=20du=20rendu=20de=20l?= =?UTF-8?q?a=20carte=20org?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/figures.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/figures.py b/src/figures.py index f92e07f..4670723 100644 --- a/src/figures.py +++ b/src/figures.py @@ -227,12 +227,13 @@ def point_on_map(lat, lon, departement_code=None): lat=[lat], lon=[lon], height=300, - width=400, + # width=400, color=[1], - size=[15], # Point plus grand comme demandé zoom=settings["zoom"], ) + fig.update_traces(marker=dict(size=10)) + # Configuration de la carte (interactive - zoomable) fig.update_layout( map_style="light", # Fond de carte clair @@ -242,7 +243,9 @@ def point_on_map(lat, lon, departement_code=None): coloraxis_showscale=False, ) - return html.Div(dcc.Graph(figure=fig), style={"width": "400px"}) + return html.Div( + dcc.Graph(figure=fig, config={"displayModeBar": False}), + ) class DataTable(dash_table.DataTable): From edbdeaa370a623abb42b95abffcaf4cf467c4ac1 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 29 Apr 2026 18:25:22 +0200 Subject: [PATCH 035/151] =?UTF-8?q?Suppression=20des=20espaces=20dans=20le?= =?UTF-8?q?s=20SIREN/SIRET=20entr=C3=A9s=20dans=20l'observatoire=20et=20le?= =?UTF-8?q?=20tableau=20#75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/table_sql.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index 8e8badd..756bde8 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -57,6 +57,8 @@ def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list value = raw_value.strip('"') if operator == "contains": + if col_name in ("acheteur_id", "titulaire_id"): + value = value.replace(" ", "") where_clause, param_list = tokenize_text_filter(col_name, value) clauses.append(where_clause) params.extend(param_list) @@ -129,6 +131,7 @@ def dashboard_filters_to_sql( params.append(datetime.now() - timedelta(days=365)) if dashboard_acheteur_id: + dashboard_acheteur_id = dashboard_acheteur_id.replace(" ", "") clauses.append('"acheteur_id" LIKE ?') params.append(f"%{dashboard_acheteur_id}%") else: @@ -141,6 +144,7 @@ def dashboard_filters_to_sql( params.extend(dashboard_acheteur_departement_code) if dashboard_titulaire_id: + dashboard_titulaire_id = dashboard_titulaire_id.replace(" ", "") clauses.append('"titulaire_id" LIKE ?') params.append(f"%{dashboard_titulaire_id}%") else: From 0322c20513603de65f331eb573c725a77212bb23 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 29 Apr 2026 18:41:45 +0200 Subject: [PATCH 036/151] Suppression des espaces pour l'affichage du nom de l'org #75 --- src/pages/observatoire.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/observatoire.py b/src/pages/observatoire.py index e58c9ba..716037e 100644 --- a/src/pages/observatoire.py +++ b/src/pages/observatoire.py @@ -824,6 +824,9 @@ def toggle_montant_modal(n_triggers, _close): prevent_initial_call=False, ) def add_organization_name_in_title(acheteur_id, titulaire_id): + acheteur_id = acheteur_id.replace(" ", "") if acheteur_id else None + titulaire_id = titulaire_id.replace(" ", "") if titulaire_id else None + def lookup_nom(df_org, id_col, nom_col, org_id): match = df_org.filter(pl.col(id_col) == org_id) return match[nom_col].item(0) if match.height >= 1 else None From f4b57dbe5cc78d4122eb3ed8852ae77a779ad68b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 5 May 2026 14:08:50 +0200 Subject: [PATCH 037/151] Gestion des filtres de dates comme du texte #76 --- src/utils/table_sql.py | 21 +++++++++++++++++---- tests/test_main.py | 26 +++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index 756bde8..2c3dcdd 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -57,12 +57,18 @@ def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list value = raw_value.strip('"') if operator == "contains": + if col_is_date: + target = f"CAST({quoted_col} AS VARCHAR)" + if col_name in ("acheteur_id", "titulaire_id"): value = value.replace(" ", "") - where_clause, param_list = tokenize_text_filter(col_name, value) + where_clause, param_list = tokenize_text_filter( + col_name, value, col_is_date + ) clauses.append(where_clause) params.extend(param_list) logger.debug(params) + continue elif operator in (">", "<"): target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col @@ -207,15 +213,22 @@ def dashboard_filters_to_sql( return " AND ".join(clauses), params -def tokenize_text_filter(column: str, text: str) -> tuple[str, list]: +def tokenize_text_filter( + column: str, text: str, col_is_date: bool = False +) -> tuple[str, list]: terms = text.split() + # si col_is_date alors le deuxième doit être casté en VARCHAR + if col_is_date: + quoted_col = f'CAST("{column}" AS VARCHAR)' + else: + quoted_col = '"column"' - conditions = [f'"{column}" IS NOT NULL', f"\"{column}\" <> ''"] + conditions = [f'"{column}" IS NOT NULL', f"{quoted_col} <> ''"] params = [] for term in terms: - conditions.append(f'"{column}" ILIKE ?') + conditions.append(f"{quoted_col} ILIKE ?") if term.startswith("*") or term.endswith("*"): params.append(term.replace("*", "%")) diff --git a/tests/test_main.py b/tests/test_main.py index ae9b36c..5a606cf 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -292,7 +292,7 @@ def test_011_observatoire_multi_param_url(dash_duo: DashComposite): ) -def test_get_distance_histogram_returns_graph(): +def test_012_get_distance_histogram_returns_graph(): import polars as pl from dash import dcc @@ -303,7 +303,7 @@ def test_get_distance_histogram_returns_graph(): assert isinstance(result, dcc.Graph) -def test_get_distance_histogram_handles_nulls(): +def test_013_get_distance_histogram_handles_nulls(): import polars as pl from dash import dcc @@ -314,7 +314,7 @@ def test_get_distance_histogram_handles_nulls(): assert isinstance(result, dcc.Graph) -def test_get_distance_histogram_all_nulls(): +def test_014_get_distance_histogram_all_nulls(): import polars as pl from dash import dcc @@ -322,4 +322,24 @@ def test_get_distance_histogram_all_nulls(): lff = pl.LazyFrame({"titulaire_distance": pl.Series([], dtype=pl.Int64)}) result = get_distance_histogram(lff) + assert isinstance(result, dcc.Graph) + + +def test_015_tableau_filter_date(dash_duo: DashComposite): + from src.app import app + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4) + + for page in ["tableau", "acheteurs/123", "titulaires/345"]: + dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}") + filter_input = '.marches_table th[data-dash-column="dateNotification"] input' + filter_cell_result = '.marches_table td[data-dash-column="dateNotification"] p' + dash_duo.wait_for_element(filter_input, timeout=2) + _filter_input: WebElement = dash_duo.find_element(filter_input) + _filter_input.send_keys("2024") # a dateNotification that doesn't exist + _filter_input.send_keys(Keys.ENTER) + _filter_result: list[WebElement] = dash_duo.find_elements(filter_cell_result) + + assert len(_filter_result) == 0 From 755b8c13abc607f6681ae2a6c856b6751a576860 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 5 May 2026 14:56:49 +0200 Subject: [PATCH 038/151] =?UTF-8?q?Bug=20nom=20colonne,=20am=C3=A9lioratio?= =?UTF-8?q?n=20test=20#76?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/table_sql.py | 2 +- tests/test_main.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index 2c3dcdd..bb84647 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -221,7 +221,7 @@ def tokenize_text_filter( if col_is_date: quoted_col = f'CAST("{column}" AS VARCHAR)' else: - quoted_col = '"column"' + quoted_col = f'"{column}"' conditions = [f'"{column}" IS NOT NULL', f"{quoted_col} <> ''"] diff --git a/tests/test_main.py b/tests/test_main.py index 5a606cf..9070ba4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -338,8 +338,7 @@ def test_015_tableau_filter_date(dash_duo: DashComposite): filter_cell_result = '.marches_table td[data-dash-column="dateNotification"] p' dash_duo.wait_for_element(filter_input, timeout=2) _filter_input: WebElement = dash_duo.find_element(filter_input) - _filter_input.send_keys("2024") # a dateNotification that doesn't exist + _filter_input.send_keys("3333") # a dateNotification that doesn't exist _filter_input.send_keys(Keys.ENTER) _filter_result: list[WebElement] = dash_duo.find_elements(filter_cell_result) - - assert len(_filter_result) == 0 + assert len(_filter_result) == 0, f"Page : {page}" From 9d7f33905f09102da033c9c032896569b71ff253 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 5 May 2026 15:05:36 +0200 Subject: [PATCH 039/151] Bumped version 2.7.6 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b1e727..09d58a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +##### 2.7.6 (5 mai 2026) + +- Correction du problème de filtre par date dans les tableaux +- Retour des cartes dans les pages acheteur et titulaire +- Possibilité de chercher un SIRET/SIREN avec des espaces dans les champs `SIRET acheteur` et `Identifiant titulaire` + ##### 2.7.5 (24 avril 2026) - Amélioration des permormances de l'observatoire diff --git a/pyproject.toml b/pyproject.toml index 873a560..e5a73b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "decp.info" description = "Interface d'exploration et d'analyse des marchés publics français." -version = "2.7.5" +version = "2.7.6" requires-python = ">= 3.10" authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }] dependencies = [ @@ -14,7 +14,7 @@ dependencies = [ "xlsxwriter", "plotly[express]", "httpx", - "pandas", # utilisé pour la création de certains graphiques + "pandas", # utilisé pour la création de certains graphiques "unidecode", "dash-leaflet", "dash-extensions", From 62eb4d98f051a1d8a5b6604ecc023a22549aa472 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 5 May 2026 15:32:27 +0200 Subject: [PATCH 040/151] Correction de l'ajout de CORS --- run.py | 3 ++- uv.lock | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/run.py b/run.py index a3e312a..2e22e04 100644 --- a/run.py +++ b/run.py @@ -3,7 +3,8 @@ from flask_cors import CORS from src.app import app # To use `gunicorn run:server` (prod) -server = CORS(app.server) +server = app.server +CORS(server) # To use `python run.py` (dev) if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index db47a6d..9ac4ecc 100644 --- a/uv.lock +++ b/uv.lock @@ -760,7 +760,7 @@ wheels = [ [[package]] name = "decp-info" -version = "2.7.5" +version = "2.7.6" source = { virtual = "." } dependencies = [ { name = "dash", extra = ["compress"] }, From 3a73cdf4b9433f7b6926abe4fd04a8cfca917436 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 5 May 2026 15:43:08 +0200 Subject: [PATCH 041/151] P dans les announcements plus compacts --- src/assets/css/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index ecba9b1..dcb90d6 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -144,6 +144,10 @@ p.version > a { max-width: 900px; } +#announcements p { + margin-bottom: 0.2rem; +} + .seeBorder { border: dotted 1px green; } From 8a2f7f620c05092388668d0f37d1b074ef7f87e8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 11 May 2026 10:24:58 +0200 Subject: [PATCH 042/151] =?UTF-8?q?Suppression=20des=20mentions=20sur=20le?= =?UTF-8?q?s=20profils=20d'acheteurs=20qui=20ne=20publient=20pas=20de=20do?= =?UTF-8?q?nn=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Omnikles publie via l'API DUME de l'AIFE, ne reste que Klekoon. Mais bon, ça fait des mauvaises vibes sur la page de le préciser :) --- src/pages/a-propos.py | 9 +-------- uv.lock | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/pages/a-propos.py b/src/pages/a-propos.py index 91bb01b..26d2ba7 100644 --- a/src/pages/a-propos.py +++ b/src/pages/a-propos.py @@ -87,14 +87,7 @@ Vous pouvez consommer les données qui alimentent decp.info dcc.Markdown( """Les données visibles sur ce site proviennent exclusivement de la publication de données ouvertes par les acheteurs publics ou en leur nom, régie par [l'arrêté du 22 décembre 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046850496). Leur qualité est donc principalement liée à la qualité de leur saisie par les agents publics, parfois peu aidé·es par la qualité des outils à leur disposition. Je pense que l'analyse de marchés individuels et le comptage de marchés sur des critères autres que financiers sont plutôt fiables. En revanche, certains montants de marché estimés à des valeurs farfelues ([1 euro](https://decp.info/marches/432766947000192025S01301), [1 milliard](https://decp.info/marches/2459004280001320210000000271)) faussent les calculs par aggrégation (sommes, moyennes, médianes) et donc la production de statistiques financières fiables. Acheteurs, acheteuses : s'il vous plaît, essayez d'estimer les montants des marchés publics attribués de manière plus précise. -Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [ci-dessous](/a-propos#sources). Certains profils d'acheteurs ne publient pas leurs données malgré l'obligation réglementaire : - -- klekoon.fr (ils y travaillent) -- safetender.com (Omnikles) - -**marches-publics.info** (AWS) publie ses données de manière assez sporadique depuis début 2023. Compte tenu de son poids dans le secteur, c'est assez dommageable pour la transparence des marchés publics. - -Au milieu de ces mauvaises nouvelles, je tiens à souligner la belle continuité de la publication par la DGFiP des données des marchés publics remontées via le [protocole PES](https://www.collectivites-locales.gouv.fr/finances-locales/le-protocole-dechange-standard-pes). Merci à leurs équipes.""" +Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [ci-dessous](/bin.usr-is-merged/)). Je tiens à souligner la belle continuité de la publication par la DGFiP des données des marchés publics remontées via le [protocole PES](https://www.collectivites-locales.gouv.fr/finances-locales/le-protocole-dechange-standard-pes). Merci à leurs équipes.""" ), html.H4("Sources de données ", id="sources"), get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")), diff --git a/uv.lock b/uv.lock index db47a6d..9ac4ecc 100644 --- a/uv.lock +++ b/uv.lock @@ -760,7 +760,7 @@ wheels = [ [[package]] name = "decp-info" -version = "2.7.5" +version = "2.7.6" source = { virtual = "." } dependencies = [ { name = "dash", extra = ["compress"] }, From cd468a837cfb8fa716dc7911c4f579be16cdf5a9 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 11 May 2026 10:31:18 +0200 Subject: [PATCH 043/151] Changelog v2.7.7 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09d58a3..4d5dbcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +##### 2.7.7 (11 mai 2026) + +- Suppression des mentions sur les profils d'acheteur. Omnikles/Safetender publie via l'API DUME et Klekoon ne publie pas, mais c'est peut-être pas le seul, donc je préfère supprimer et refaire un tour. + ##### 2.7.6 (5 mai 2026) - Correction du problème de filtre par date dans les tableaux diff --git a/pyproject.toml b/pyproject.toml index e5a73b1..a1e0f52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "decp.info" description = "Interface d'exploration et d'analyse des marchés publics français." -version = "2.7.6" +version = "2.7.7" requires-python = ">= 3.10" authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }] dependencies = [ From 0c9666204bf825c07689fe02217f494a4ca639fd Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 11 May 2026 10:34:06 +0200 Subject: [PATCH 044/151] Autorisation du HTML dans les annonces --- src/app.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app.py b/src/app.py index 5e9e490..40a40d5 100644 --- a/src/app.py +++ b/src/app.py @@ -145,7 +145,13 @@ navbar = dbc.Navbar( style={"minWidth": "230px"}, ), dbc.Nav( - children=[dcc.Markdown(os.getenv("ANNOUNCEMENTS"), id="announcements")], + children=[ + dcc.Markdown( + os.getenv("ANNOUNCEMENTS"), + id="announcements", + dangerously_allow_html=True, + ), + ], style={ "maxWidth": "1200px", "display": "inline-block", From 8fb1a2084db3a3998a6d212ebd3d565c31a24c6f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 12:18:35 +0200 Subject: [PATCH 045/151] =?UTF-8?q?Spec=20:=20API=20priv=C3=A9e=20tabulair?= =?UTF-8?q?e=20avec=20tokens=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design retenu : blueprint Flask + flask-smorest sur /api/v1, endpoint tabulaire générique aligné sur le swagger data.gouv.fr, tokens Bearer admin manuels (CLI), suivi via Matomo async + compteurs SQLite légers. Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-05-13-api-privee-design.md | 428 ++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-13-api-privee-design.md diff --git a/docs/superpowers/specs/2026-05-13-api-privee-design.md b/docs/superpowers/specs/2026-05-13-api-privee-design.md new file mode 100644 index 0000000..0a838c3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-api-privee-design.md @@ -0,0 +1,428 @@ +# API privée decp.info — Design + +**Date** : 2026-05-13 +**Statut** : design validé, en attente du plan d'implémentation + +## 1. Contexte et objectifs + +decp.info reçoit des demandes récurrentes pour un accès programmatique aux +données DECP exposées par l'application web. Le besoin est d'ouvrir une API +HTTP **privée** (accès sur token), inspirée de l'API tabulaire de data.gouv.fr +(https://tabular-api.data.gouv.fr/api/resources/22847056-61df-452d-837d-8b8ceadbfc52/swagger/), +qu'un utilisateur en cours s'est déjà appropriée comme référence. + +Objectifs explicites : + +- Réponses rapides. +- API documentée (OpenAPI + Swagger UI). +- Suivi de la consommation par utilisateur. + +Non-objectifs (V1) : + +- Self-service de création de tokens via UI web. +- Rate-limiting / quotas. +- Formats de sortie autres que JSON (CSV, Parquet…). +- Endpoints sémantiques métier (`/acheteurs/{id}`, etc.). + +## 2. Choix structurants + +### 2.1 Framework : Flask + flask-smorest + +L'API est ajoutée à l'application Flask existante (serveur Dash) sous forme +d'un blueprint flask-smorest monté sur `/api/v1`. Choix motivé par : + +- L'app Dash actuelle tourne déjà sur Flask via gunicorn. +- DuckDB est ouvert une seule fois au boot dans `src/db.py` (`conn` read-only) + et peut être partagé directement par les endpoints API. +- L'API est tabulaire avec filtres **dynamiques** : la liste des colonnes et + des types vient du schéma DuckDB, pas d'une déclaration Pydantic. Les + bénéfices de FastAPI (auto-validation Pydantic) sont donc faibles. +- flask-smorest génère OpenAPI + sert Swagger UI nativement. +- Un seul process, un seul serveur, un seul déploiement. + +Alternatives écartées : + +- **FastAPI séparé reverse-proxié** : deux processus, ops plus complexe, + bénéfice marginal vu les filtres dynamiques. +- **FastAPI englobant Flask via WSGIMiddleware** : changerait le serveur de + toute l'app Dash existante, migration risquée. + +### 2.2 Style d'API : tabulaire générique + +Un endpoint unique de requête (`/api/v1/data`) avec filtres dynamiques sur +toutes les colonnes du schéma, à l'image du swagger cible. Aucun endpoint +sémantique métier en V1. + +### 2.3 Authentification : tokens admin manuels + +Tokens Bearer émis manuellement par l'admin via un CLI. Pas de page web de +gestion en V1. Modèle prévu pour se lier ultérieurement aux comptes +utilisateurs (cf. `comptes_utilisateurs.md`) sans migration de données. + +### 2.4 Suivi de consommation : Matomo asynchrone + compteurs locaux + +- Matomo en fire-and-forget pour l'analyse fine (qui, quand, quoi, code HTTP). +- Compteurs locaux SQLite (`count_total`, `last_used_at`) pour identifier + les tokens inactifs et préparer un éventuel rate-limit futur. + +## 3. Architecture + +### 3.1 Arborescence + +``` +src/api/ +├── __init__.py # init_api(server) — enregistre le blueprint flask-smorest +├── routes.py # endpoints /data, /schema, /health +├── schemas.py # marshmallow : query params, réponses +├── filters.py # parsing & validation `col__op=val` → (where_sql, params) +├── auth.py # décorateur @require_token, header Authorization Bearer +├── tracking.py # worker thread compteurs SQLite + httpx fire-and-forget Matomo +├── tokens_db.py # CRUD api_tokens dans users.sqlite +└── tokens_cli.py # python -m src.api.tokens_cli create|list|revoke +``` + +`src/auth/` reste réservé aux comptes utilisateurs interactifs +(`comptes_utilisateurs.md`), distincts des tokens API. + +### 3.2 Branchement + +Dans `src/app.py`, après l'init Dash : + +```python +from src.api import init_api +init_api(app.server) +``` + +`init_api` enregistre le blueprint sur `/api/v1` et expose : + +- `/api/v1/data` +- `/api/v1/schema` +- `/api/v1/health` +- `/api/v1/swagger` (UI) +- `/api/v1/openapi.json` + +### 3.3 Partage de la connexion DuckDB + +Les routes importent `src.db.conn` et utilisent les helpers existants +(`query_marches`, `count_marches`) ainsi que `src.db.schema` (Polars Schema) +pour la whitelist de colonnes. + +## 4. Stockage + +### 4.1 SQLite consolidée + +Une seule base SQLite, `users.sqlite` à la racine, contient : + +- `users` (futur — cf. `comptes_utilisateurs.md`) +- `api_tokens` (V1) + +Bénéfice : un seul fichier à sauvegarder et migrer ; la liaison future +`api_tokens.user_id → users.id` est immédiate sans migration de données. + +### 4.2 Schéma `api_tokens` + +```sql +CREATE TABLE api_tokens ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + user_id INTEGER, + created_at TEXT NOT NULL, + last_used_at TEXT, + count_total INTEGER NOT NULL DEFAULT 0, + revoked_at TEXT +); +CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash); +``` + +`user_id` est `NULL` pour les tokens admin manuels. Quand le self-service +arrivera, il suffira de le renseigner. + +## 5. Endpoints + +### 5.1 Vue d'ensemble + +| Méthode | Path | Auth | Rôle | +| ------- | ---------------------- | ------ | ------------------------------------------- | +| GET | `/api/v1/data` | Bearer | Endpoint tabulaire principal | +| GET | `/api/v1/schema` | Bearer | Liste des colonnes (nom, type, description) | +| GET | `/api/v1/health` | Aucune | Sonde monitoring | +| GET | `/api/v1/swagger` | Aucune | Swagger UI | +| GET | `/api/v1/openapi.json` | Aucune | Spec OpenAPI | + +### 5.2 `/api/v1/data` — langage de requête + +Filtres en query string, opérateurs suffixés par `__` (mirror swagger cible) : + +| Opérateur | Sens | +| -------------------- | ----------------------------------------------------- | +| `__exact` | égalité | +| `__contains` | sous-chaîne (LIKE %v%) | +| `__notcontains` | négation de `__contains` | +| `__less` | ≤ | +| `__greater` | ≥ | +| `__strictly_less` | < | +| `__strictly_greater` | > | +| `__in` | liste séparée par virgules | +| `__notin` | négation de `__in` | +| `__isnull` | `IS NULL` (valeur ignorée) | +| `__isnotnull` | `IS NOT NULL` (valeur ignorée) | +| `__sort` | `asc` ou `desc` — ordre = ordre des params dans l'URL | + +Autres paramètres réservés : + +- `page` (int, défaut 1, ≥1) +- `page_size` (int, défaut 50, max 1000) +- `columns` (string, liste séparée par virgules ; défaut = toutes) +- `count` (bool, défaut `true` ; `false` → `meta.total` absent, économise un `COUNT(*)`) + +Exemple : + +``` +GET /api/v1/data?acheteur_departement_code__exact=44 + &dateNotification__greater=2024-01-01 + &montant__strictly_greater=100000 + &objet__contains=informatique + &cpv_8__in=72000000,72200000 + &dateNotification__sort=desc + &page=1 + &page_size=50 + &columns=uid,objet,montant,dateNotification +``` + +### 5.3 Sécurité du parsing + +`filters.py` est l'unique chemin de génération du `WHERE` SQL : + +1. Chaque clé `__` est splittée puis validée : + - `` doit être dans `src.db.schema` (whitelist stricte). + - `` doit être dans la liste blanche d'opérateurs. + - La valeur est convertie selon le type Polars de la colonne : + - `String` : utilisée telle quelle. + - `Int*` : `int(value)`, 400 si non parseable. + - `Float*` : `float(value)`, 400 si non parseable. + - `Date` / `Datetime` : ISO 8601 (`YYYY-MM-DD` ou `YYYY-MM-DDTHH:MM:SS`), 400 sinon. + - Booléens : **les colonnes booléennes sont stockées comme strings + "oui"/"non" en DuckDB** (cf. `src/db.py:43`), donc traitées comme + `String`. L'utilisateur filtre avec `colonne__exact=oui`. +2. Le `WHERE` est composé de fragments paramétrés (`?`) ; les valeurs + utilisateur sont passées au moteur DuckDB via les paramètres, **jamais + concaténées** dans le SQL. +3. Le résultat est consommé par `src.db.query_marches(where_sql=..., params=...)` + qui existe déjà. + +### 5.4 Format de réponse + +```json +{ + "data": [{ "uid": "...", "objet": "...", "montant": 12345.0 }], + "meta": { "page": 1, "page_size": 50, "total": 1234 }, + "links": { + "next": "/api/v1/data?...&page=2", + "prev": null + } +} +``` + +`meta.total` est omis si `count=false`. `links.next`/`links.prev` sont +`null` aux extrémités. + +### 5.5 `/api/v1/schema` + +```json +{ + "columns": [ + { "name": "uid", "type": "string", "description": "..." }, + { "name": "montant", "type": "float", "description": "..." } + ] +} +``` + +Descriptions tirées de `../decp-processing/reference/base_schema.json` si +disponible ; sinon vides. + +### 5.6 V1 : JSON only + +Pas de CSV / Parquet. Ajout possible plus tard via `?format=`. + +## 6. Authentification + +### 6.1 Transmission + +Header HTTP standard : + +``` +Authorization: Bearer dcpinfo_a1b2c3d4... +``` + +Pas de support via query string (fuites dans les logs). + +### 6.2 Format du token + +Préfixe `dcpinfo_` + 32 octets aléatoires hex (43 caractères au total). +Le préfixe facilite la détection de fuites (gitleaks, etc.). + +### 6.3 Hashing + +`sha256(token)` stocké dans `api_tokens.token_hash`. Pas de bcrypt/argon2 : +les tokens ont 256 bits d'entropie, le brute-force est impossible et un +hash lent ralentirait inutilement chaque requête API. + +### 6.4 Décorateur `@require_token` + +1. Lit `Authorization` ; absent → 401 `missing_token`. +2. Calcule `sha256`, `SELECT` indexé. +3. Pas trouvé → 401 `invalid_token`. +4. `revoked_at IS NOT NULL` → 401 `revoked_token`. +5. Pose `flask.g.token_id` pour `tracking.py`. + +### 6.5 CLI de gestion + +`python -m src.api.tokens_cli` : + +``` +create --label "Marie Dupont - étude transport 2026" + → affiche UNE FOIS le token plaintext (irrécupérable ensuite) + +list + → id | label | created_at | last_used_at | count_total | revoked? + +revoke + → set revoked_at = now() (ISO 8601 UTC) +``` + +Pas d'UI web pour les tokens en V1. + +## 7. Suivi de consommation + +### 7.1 Hook + +`@bp.after_request` déclenche deux actions **sans bloquer la réponse** : + +1. Enfilage d'un update SQLite dans une `queue.Queue` consommée par un + worker thread unique (writer série, pas de contention SQLite). +2. POST httpx fire-and-forget vers la Tracking API Matomo. + +Les erreurs des deux chemins sont loggées en `warning` mais jamais propagées +à l'utilisateur. + +### 7.2 Update SQLite + +```sql +UPDATE api_tokens +SET count_total = count_total + 1, + last_used_at = ? +WHERE id = ? +``` + +### 7.3 Event Matomo + +``` +POST https://analytics.maudry.com/matomo.php + idsite=14 + rec=1 + url=https://decp.info/api/v1/data? + action_name=API /data + uid=token- # jamais le token plaintext + dimension1= + dimension2= + ua= +``` + +Custom Dimensions à créer côté Matomo : `dimension1=token_id`, +`dimension2=http_status`. + +### 7.4 Variables d'environnement nouvelles + +``` +MATOMO_URL=https://analytics.maudry.com/matomo.php +MATOMO_SITE_ID=14 +MATOMO_TRACKING_ENABLED=true # false en dev/test par défaut +USERS_DB_PATH=./users.sqlite # tests : tests/users.test.sqlite +``` + +## 8. Erreurs + +Format uniforme (RFC 7807, déjà standard flask-smorest) : + +```json +{ + "code": 400, + "status": "Bad Request", + "message": "Colonne inconnue 'foo'.", + "errors": { "field": "foo__exact" } +} +``` + +| HTTP | Cas | +| ---- | --------------------------------------------------------------------- | +| 200 | Succès | +| 400 | Colonne/opérateur/valeur invalide, `page_size` hors bornes | +| 401 | `missing_token` / `invalid_token` / `revoked_token` | +| 404 | Path API inexistant | +| 500 | Exception non gérée — message générique, stack trace loggée seulement | + +Pas de 429 en V1. + +Les 4xx sont loggées en `info` (path + token_id), les 500 en `error` avec +stack trace. + +## 9. Tests + +Tests pytest purs (pas de Selenium) via `app.server.test_client()`. + +``` +tests/api/ +├── test_filters.py # parsing, génération SQL/params, erreurs +├── test_auth.py # 401 cases, last_used_at update +├── test_tokens_cli.py # create/list/revoke +├── test_endpoints_data.py # pagination, filtres, sort, columns, count=false +├── test_endpoints_schema.py # /schema renvoie les colonnes attendues +├── test_health.py # /health 200 sans auth +└── test_tracking.py # compteurs SQLite, Matomo désactivé par défaut + mock httpx +``` + +Fixtures pytest : + +- `api_client` : `app.server.test_client()` +- `valid_token_header` : crée un token dans `tests/users.test.sqlite`, renvoie le header `Authorization: Bearer …` +- `revoked_token_header` : idem avec `revoked_at` set + +Ajouts `pyproject.toml` `[tool.pytest.ini_options].env` : + +``` +USERS_DB_PATH=tests/users.test.sqlite +MATOMO_TRACKING_ENABLED=false +``` + +Couverture cible : 100% de `filters.py` et `auth.py` (sécurité-critique) ; +raisonnable ailleurs. + +## 10. Dépendances nouvelles + +À ajouter dans `pyproject.toml` : + +- `flask-smorest` (blueprint + OpenAPI + Swagger UI) +- `marshmallow` (déjà transitif de flask-smorest, à expliciter) + +`httpx` est déjà présent. Pas d'autres dépendances. + +## 11. Documentation utilisateur + +À fournir séparément (hors scope spec, à inclure dans le plan d'implémentation) : + +- Section "API" dans la page À propos ou page dédiée `/api` avec : + - lien vers Swagger UI + - exemples curl + - procédure pour obtenir un token (« contactez X ») +- Mention dans le `CHANGELOG.md` à la sortie de version. + +## 12. Risques et points ouverts + +- **Coût du `COUNT(*)`** sur gros filtres : mitigé par `count=false` opt-out. +- **Charge SQLite write** : un worker série suffira pour le trafic attendu + (admin tokens manuels, faible volume). Si le volume monte, passer à un + buffer en RAM avec flush périodique. +- **Matomo down** : impact nul sur l'API (fire-and-forget loggué). +- **Évolution vers self-service** : déjà préparée par `user_id` nullable et + séparation `src/api/` vs `src/auth/`. From 19d69e965f87f7a8f837dec383b335daca7e9e32 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 12:29:41 +0200 Subject: [PATCH 046/151] =?UTF-8?q?Format=20pr=C3=A9fixe=20token=20#78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/2026-05-13-api-privee-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-05-13-api-privee-design.md b/docs/superpowers/specs/2026-05-13-api-privee-design.md index 0a838c3..151f170 100644 --- a/docs/superpowers/specs/2026-05-13-api-privee-design.md +++ b/docs/superpowers/specs/2026-05-13-api-privee-design.md @@ -252,14 +252,14 @@ Pas de CSV / Parquet. Ajout possible plus tard via `?format=`. Header HTTP standard : ``` -Authorization: Bearer dcpinfo_a1b2c3d4... +Authorization: Bearer decpinfo_a1b2c3d4... ``` Pas de support via query string (fuites dans les logs). ### 6.2 Format du token -Préfixe `dcpinfo_` + 32 octets aléatoires hex (43 caractères au total). +Préfixe `decpinfo_` + 32 octets aléatoires hex (43 caractères au total). Le préfixe facilite la détection de fuites (gitleaks, etc.). ### 6.3 Hashing From 0363e89301e954fc01cbffe91d728af1b7d09e18 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 12:55:09 +0200 Subject: [PATCH 047/151] =?UTF-8?q?Plan=20:=20impl=C3=A9mentation=20API=20?= =?UTF-8?q?priv=C3=A9e=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 tâches TDD couvrant deps, tokens_db, CLI, filters, auth, blueprint, endpoints (/health, /schema, /data), tracking SQLite + Matomo, OpenAPI, changelog, mention page À propos. Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-05-13-api-privee.md | 1992 +++++++++++++++++ 1 file changed, 1992 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-13-api-privee.md diff --git a/docs/superpowers/plans/2026-05-13-api-privee.md b/docs/superpowers/plans/2026-05-13-api-privee.md new file mode 100644 index 0000000..c9ad70a --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-api-privee.md @@ -0,0 +1,1992 @@ +# API privée decp.info — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Livrer une API HTTP privée tabulaire `/api/v1/data` protégée par tokens Bearer, documentée via OpenAPI/Swagger, avec suivi de consommation (Matomo + compteurs SQLite). + +**Architecture:** Blueprint flask-smorest monté sur le serveur Flask existant (sous Dash), partage la connexion DuckDB read-only de `src/db.py`. Tokens stockés hashés (SHA-256) dans `users.sqlite`, CLI d'administration. Suivi async par worker thread (SQLite) + httpx fire-and-forget (Matomo). + +**Tech Stack:** Flask, flask-smorest, marshmallow, DuckDB (existant), SQLite (stdlib), httpx (existant), Polars (existant), pytest. + +**Spec de référence :** `docs/superpowers/specs/2026-05-13-api-privee-design.md` + +--- + +## File Structure + +**À créer :** + +``` +src/api/ +├── __init__.py # init_api(server) +├── routes.py # endpoints /data, /schema, /health +├── schemas.py # marshmallow request/response schemas +├── filters.py # parsing & validation des filtres +├── auth.py # @require_token +├── tracking.py # worker SQLite + Matomo httpx +├── tokens_db.py # CRUD api_tokens +└── tokens_cli.py # python -m src.api.tokens_cli ... + +tests/api/ +├── __init__.py +├── conftest.py # fixtures api_client, valid_token_header, revoked_token_header +├── test_tokens_db.py +├── test_tokens_cli.py +├── test_filters.py +├── test_auth.py +├── test_health.py +├── test_endpoints_schema.py +├── test_endpoints_data.py +└── test_tracking.py +``` + +**À modifier :** + +- `pyproject.toml` : nouvelles deps + nouvelles variables `pytest-env` +- `.template.env` : nouvelles variables `MATOMO_*` et `USERS_DB_PATH` +- `src/app.py` : appel à `init_api(app.server)` après l'init Dash +- `CHANGELOG.md` : entrée pour la version qui livre l'API + +--- + +## Task 1: Dépendances et infrastructure de test + +**Files:** + +- Modify: `pyproject.toml` +- Modify: `.template.env` +- Create: `src/api/__init__.py` (vide) +- Create: `tests/api/__init__.py` (vide) + +- [ ] **Step 1: Ajouter `flask-smorest` aux dépendances** + +Dans `pyproject.toml`, section `[project].dependencies`, ajouter après `"flask-cors>=6.0.2"` : + +```toml + "flask-smorest>=0.46.0", + "marshmallow>=3.20.0", +``` + +- [ ] **Step 2: Ajouter les variables d'env pytest** + +Dans `pyproject.toml`, section `[tool.pytest.ini_options].env`, ajouter à la liste : + +```toml + "USERS_DB_PATH=tests/users.test.sqlite", + "MATOMO_TRACKING_ENABLED=false", +``` + +- [ ] **Step 3: Étendre `.template.env`** + +Ajouter à la fin de `.template.env` : + +```bash + +# API privée +USERS_DB_PATH=./users.sqlite +MATOMO_URL=https://analytics.maudry.com/matomo.php +MATOMO_SITE_ID=14 +MATOMO_TRACKING_ENABLED=true +``` + +- [ ] **Step 4: Installer les nouvelles dépendances** + +Run: `source .venv/bin/activate && rtk pip install -e . --group=dev` + +Expected: install OK, `pip show flask-smorest` shows version. + +- [ ] **Step 5: Créer les packages vides** + +Créer `src/api/__init__.py` avec contenu vide (juste un commentaire). + +```python +# decp.info — API privée. init_api(server) sera ajouté Task 6. +``` + +Créer `tests/api/__init__.py` avec contenu vide. + +- [ ] **Step 6: Vérifier que pytest tourne toujours** + +Run: `rtk pre-commit run --all-files` +Expected: PASS (formatages éventuels appliqués). + +Run: `rtk pytest tests/ -x --co -q 2>&1 | tail -20` +Expected: la collection se fait sans erreur (peu importe le nb de tests). + +- [ ] **Step 7: Commit** + +```bash +rtk git add pyproject.toml .template.env src/api/__init__.py tests/api/__init__.py +rtk git commit -m "API: dépendances et squelette de package (#78)" +``` + +--- + +## Task 2: Module `tokens_db` (SQLite CRUD) + +**Files:** + +- Create: `src/api/tokens_db.py` +- Create: `tests/api/conftest.py` +- Create: `tests/api/test_tokens_db.py` + +- [ ] **Step 1: Écrire la fixture `temp_db` partagée** + +Créer `tests/api/conftest.py` : + +```python +import os +from pathlib import Path + +import pytest + + +@pytest.fixture +def temp_db(tmp_path, monkeypatch): + """Une SQLite éphémère pour les tests qui modifient la DB.""" + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + from src.api import tokens_db + tokens_db.init_schema(db_path) + return db_path +``` + +- [ ] **Step 2: Écrire les tests de `tokens_db`** + +Créer `tests/api/test_tokens_db.py` : + +```python +import sqlite3 + +from src.api import tokens_db + + +def test_init_schema_creates_table(temp_db): + with sqlite3.connect(str(temp_db)) as conn: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='api_tokens'" + ).fetchall() + assert rows == [("api_tokens",)] + + +def test_create_token_returns_plaintext_and_stores_hash(temp_db): + token, token_id = tokens_db.create_token(temp_db, "test-label") + assert token.startswith("decpinfo_") + assert len(token) == len("decpinfo_") + 64 # 32 octets hex = 64 chars + assert token_id >= 1 + + with sqlite3.connect(str(temp_db)) as conn: + row = conn.execute( + "SELECT token_hash, label, count_total FROM api_tokens WHERE id = ?", + (token_id,), + ).fetchone() + assert row[1] == "test-label" + assert row[2] == 0 + assert row[0] != token # stocké en clair impossible + assert len(row[0]) == 64 # sha256 hex + + +def test_get_token_by_plaintext_returns_row(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + row = tokens_db.get_token_by_plaintext(temp_db, token) + assert row is not None + assert row["id"] == token_id + assert row["label"] == "x" + assert row["revoked_at"] is None + + +def test_get_token_unknown_returns_none(temp_db): + assert tokens_db.get_token_by_plaintext(temp_db, "decpinfo_zzz") is None + + +def test_revoke_token_sets_revoked_at(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + tokens_db.revoke_token(temp_db, token_id) + row = tokens_db.get_token_by_plaintext(temp_db, token) + assert row["revoked_at"] is not None + + +def test_increment_usage_updates_counter_and_timestamp(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + tokens_db.increment_usage(temp_db, token_id) + tokens_db.increment_usage(temp_db, token_id) + row = tokens_db.get_token_by_plaintext(temp_db, token) + assert row["count_total"] == 2 + assert row["last_used_at"] is not None + + +def test_list_tokens_returns_all(temp_db): + tokens_db.create_token(temp_db, "a") + tokens_db.create_token(temp_db, "b") + rows = tokens_db.list_tokens(temp_db) + assert [r["label"] for r in rows] == ["a", "b"] +``` + +- [ ] **Step 3: Lancer les tests et confirmer qu'ils échouent** + +Run: `rtk pytest tests/api/test_tokens_db.py -v` +Expected: tous les tests FAIL avec `ImportError` ou `AttributeError` (module non écrit). + +- [ ] **Step 4: Implémenter `tokens_db.py`** + +Créer `src/api/tokens_db.py` : + +```python +import hashlib +import secrets +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +TOKEN_PREFIX = "decpinfo_" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + user_id INTEGER, + created_at TEXT NOT NULL, + last_used_at TEXT, + count_total INTEGER NOT NULL DEFAULT 0, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); +""" + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +@contextmanager +def _connect(db_path): + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + +def init_schema(db_path) -> None: + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + with _connect(db_path) as conn: + conn.executescript(SCHEMA) + conn.commit() + + +def create_token(db_path, label: str, user_id: int | None = None) -> tuple[str, int]: + token = TOKEN_PREFIX + secrets.token_hex(32) + with _connect(db_path) as conn: + cur = conn.execute( + "INSERT INTO api_tokens (token_hash, label, user_id, created_at) " + "VALUES (?, ?, ?, ?)", + (_hash(token), label, user_id, _utcnow_iso()), + ) + conn.commit() + return token, cur.lastrowid + + +def get_token_by_plaintext(db_path, token: str) -> dict | None: + with _connect(db_path) as conn: + row = conn.execute( + "SELECT * FROM api_tokens WHERE token_hash = ?", + (_hash(token),), + ).fetchone() + return dict(row) if row else None + + +def revoke_token(db_path, token_id: int) -> None: + with _connect(db_path) as conn: + conn.execute( + "UPDATE api_tokens SET revoked_at = ? WHERE id = ?", + (_utcnow_iso(), token_id), + ) + conn.commit() + + +def increment_usage(db_path, token_id: int) -> None: + with _connect(db_path) as conn: + conn.execute( + "UPDATE api_tokens " + "SET count_total = count_total + 1, last_used_at = ? " + "WHERE id = ?", + (_utcnow_iso(), token_id), + ) + conn.commit() + + +def list_tokens(db_path) -> list[dict]: + with _connect(db_path) as conn: + rows = conn.execute("SELECT * FROM api_tokens ORDER BY id").fetchall() + return [dict(r) for r in rows] +``` + +- [ ] **Step 5: Lancer les tests, confirmer qu'ils passent** + +Run: `rtk pytest tests/api/test_tokens_db.py -v` +Expected: PASS (7 tests). + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/api/tokens_db.py tests/api/conftest.py tests/api/test_tokens_db.py +rtk git add src/api/tokens_db.py tests/api/conftest.py tests/api/test_tokens_db.py +rtk git commit -m "API: tokens_db SQLite CRUD (#78)" +``` + +--- + +## Task 3: CLI `tokens_cli` (create / list / revoke) + +**Files:** + +- Create: `src/api/tokens_cli.py` +- Create: `tests/api/test_tokens_cli.py` + +- [ ] **Step 1: Écrire les tests CLI** + +Créer `tests/api/test_tokens_cli.py` : + +```python +import subprocess +import sys + +from src.api import tokens_cli, tokens_db + + +def _run(args, env): + return tokens_cli.main(args, env=env) + + +def test_create_prints_plaintext_token_once(temp_db, capsys): + rc = _run(["create", "--label", "alice"], env={"USERS_DB_PATH": str(temp_db)}) + out = capsys.readouterr().out + assert rc == 0 + assert "decpinfo_" in out + tokens = tokens_db.list_tokens(temp_db) + assert len(tokens) == 1 + assert tokens[0]["label"] == "alice" + + +def test_list_shows_tokens(temp_db, capsys): + tokens_db.create_token(temp_db, "alice") + tokens_db.create_token(temp_db, "bob") + rc = _run(["list"], env={"USERS_DB_PATH": str(temp_db)}) + out = capsys.readouterr().out + assert rc == 0 + assert "alice" in out + assert "bob" in out + + +def test_revoke_sets_revoked_at(temp_db, capsys): + _, token_id = tokens_db.create_token(temp_db, "alice") + rc = _run( + ["revoke", str(token_id)], env={"USERS_DB_PATH": str(temp_db)} + ) + assert rc == 0 + tokens = tokens_db.list_tokens(temp_db) + assert tokens[0]["revoked_at"] is not None +``` + +- [ ] **Step 2: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_tokens_cli.py -v` +Expected: FAIL (`ImportError`). + +- [ ] **Step 3: Implémenter `tokens_cli.py`** + +Créer `src/api/tokens_cli.py` : + +```python +import argparse +import os +import sys + +from src.api import tokens_db + + +def main(argv=None, env=None) -> int: + env = env if env is not None else os.environ + parser = argparse.ArgumentParser(prog="python -m src.api.tokens_cli") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_create = sub.add_parser("create", help="Créer un token API") + p_create.add_argument("--label", required=True) + p_create.add_argument("--user-id", type=int, default=None) + + sub.add_parser("list", help="Lister les tokens") + + p_revoke = sub.add_parser("revoke", help="Révoquer un token") + p_revoke.add_argument("token_id", type=int) + + args = parser.parse_args(argv) + db_path = env["USERS_DB_PATH"] + tokens_db.init_schema(db_path) + + if args.cmd == "create": + token, token_id = tokens_db.create_token(db_path, args.label, args.user_id) + print(f"id={token_id} label={args.label}") + print(f"token (à conserver, ne sera plus affiché) : {token}") + return 0 + + if args.cmd == "list": + rows = tokens_db.list_tokens(db_path) + if not rows: + print("(aucun token)") + return 0 + print(f"{'id':<4} {'label':<40} {'created_at':<26} {'last_used_at':<26} {'count':<7} revoked") + for r in rows: + print( + f"{r['id']:<4} {r['label']:<40} {r['created_at']:<26} " + f"{(r['last_used_at'] or '-'):<26} {r['count_total']:<7} " + f"{r['revoked_at'] or ''}" + ) + return 0 + + if args.cmd == "revoke": + tokens_db.revoke_token(db_path, args.token_id) + print(f"token id={args.token_id} révoqué") + return 0 + + return 1 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) +``` + +- [ ] **Step 4: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_tokens_cli.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Tester manuellement le CLI** + +```bash +USERS_DB_PATH=/tmp/cli.test.sqlite python -m src.api.tokens_cli create --label "test" +USERS_DB_PATH=/tmp/cli.test.sqlite python -m src.api.tokens_cli list +``` + +Expected: token affiché en sortie, puis listé. + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/api/tokens_cli.py tests/api/test_tokens_cli.py +rtk git add src/api/tokens_cli.py tests/api/test_tokens_cli.py +rtk git commit -m "API: CLI tokens_cli (create/list/revoke) (#78)" +``` + +--- + +## Task 4: Module `filters` (parsing + génération SQL) + +**Files:** + +- Create: `src/api/filters.py` +- Create: `tests/api/test_filters.py` + +- [ ] **Step 1: Écrire les tests de parsing & validation** + +Créer `tests/api/test_filters.py` : + +```python +import polars as pl +import pytest + +from src.api.filters import FilterError, build_where + + +SCHEMA = pl.Schema( + { + "uid": pl.String, + "objet": pl.String, + "montant": pl.Float64, + "annee": pl.Int64, + "dateNotification": pl.Date, + } +) + + +def test_no_filters_returns_true(): + where, params, order = build_where([], SCHEMA) + assert where == "TRUE" + assert params == [] + assert order is None + + +def test_exact_filter(): + where, params, _ = build_where([("uid__exact", "abc")], SCHEMA) + assert where == '"uid" = ?' + assert params == ["abc"] + + +def test_contains_filter_uses_like_wildcards(): + where, params, _ = build_where( + [("objet__contains", "informatique")], SCHEMA + ) + assert where == '"objet" LIKE ?' + assert params == ["%informatique%"] + + +def test_notcontains_filter(): + where, params, _ = build_where( + [("objet__notcontains", "x")], SCHEMA + ) + assert where == '"objet" NOT LIKE ?' + assert params == ["%x%"] + + +def test_comparison_operators_on_int(): + where, params, _ = build_where( + [("annee__strictly_greater", "2023")], SCHEMA + ) + assert where == '"annee" > ?' + assert params == [2024 - 1] # int coercion: 2023 + + +def test_in_filter_splits_on_commas(): + where, params, _ = build_where( + [("annee__in", "2022,2023,2024")], SCHEMA + ) + assert where == '"annee" IN (?,?,?)' + assert params == [2022, 2023, 2024] + + +def test_notin_filter(): + where, params, _ = build_where( + [("annee__notin", "2020,2021")], SCHEMA + ) + assert where == '"annee" NOT IN (?,?)' + assert params == [2020, 2021] + + +def test_isnull_filter_ignores_value(): + where, params, _ = build_where( + [("dateNotification__isnull", "anything")], SCHEMA + ) + assert where == '"dateNotification" IS NULL' + assert params == [] + + +def test_isnotnull_filter(): + where, params, _ = build_where( + [("dateNotification__isnotnull", "")], SCHEMA + ) + assert where == '"dateNotification" IS NOT NULL' + + +def test_multiple_filters_joined_by_and(): + where, params, _ = build_where( + [("uid__exact", "a"), ("annee__greater", "2020")], SCHEMA + ) + assert where == '"uid" = ? AND "annee" >= ?' + assert params == ["a", 2020] + + +def test_unknown_column_raises(): + with pytest.raises(FilterError) as exc: + build_where([("foo__exact", "bar")], SCHEMA) + assert "foo" in str(exc.value) + assert exc.value.field == "foo__exact" + + +def test_unknown_operator_raises(): + with pytest.raises(FilterError) as exc: + build_where([("uid__weird", "x")], SCHEMA) + assert "weird" in str(exc.value) + + +def test_bad_int_value_raises(): + with pytest.raises(FilterError): + build_where([("annee__exact", "notanint")], SCHEMA) + + +def test_bad_date_value_raises(): + with pytest.raises(FilterError): + build_where([("dateNotification__exact", "notadate")], SCHEMA) + + +def test_date_iso_coercion(): + where, params, _ = build_where( + [("dateNotification__greater", "2024-01-01")], SCHEMA + ) + from datetime import date + assert params == [date(2024, 1, 1)] + + +def test_reserved_params_are_ignored(): + where, params, order = build_where( + [ + ("page", "2"), + ("page_size", "100"), + ("columns", "uid"), + ("count", "false"), + ("uid__exact", "z"), + ], + SCHEMA, + ) + assert where == '"uid" = ?' + assert params == ["z"] + + +def test_sort_returns_order_by(): + where, params, order = build_where( + [("annee__sort", "desc"), ("uid__sort", "asc")], SCHEMA + ) + assert where == "TRUE" + assert order == '"annee" DESC, "uid" ASC' + + +def test_sort_invalid_direction_raises(): + with pytest.raises(FilterError): + build_where([("uid__sort", "sideways")], SCHEMA) + + +def test_param_without_operator_raises(): + with pytest.raises(FilterError): + build_where([("uidexact", "x")], SCHEMA) +``` + +- [ ] **Step 2: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_filters.py -v` +Expected: FAIL avec `ImportError`. + +- [ ] **Step 3: Implémenter `filters.py`** + +Créer `src/api/filters.py` : + +```python +from datetime import date, datetime + +import polars as pl + +OPERATORS = { + "exact", + "contains", + "notcontains", + "less", + "greater", + "strictly_less", + "strictly_greater", + "in", + "notin", + "isnull", + "isnotnull", + "sort", +} + +RESERVED_PARAMS = {"page", "page_size", "columns", "count"} + + +class FilterError(ValueError): + def __init__(self, message: str, field: str | None = None): + super().__init__(message) + self.field = field + + +def _coerce(value: str, dtype: pl.DataType, key: str): + if dtype == pl.String: + return value + if dtype.is_integer(): + try: + return int(value) + except ValueError: + raise FilterError( + f"Valeur entière attendue, reçu {value!r}", field=key + ) + if dtype.is_float(): + try: + return float(value) + except ValueError: + raise FilterError( + f"Valeur décimale attendue, reçu {value!r}", field=key + ) + if dtype == pl.Date: + try: + return date.fromisoformat(value) + except ValueError: + raise FilterError( + f"Date ISO 8601 attendue (YYYY-MM-DD), reçu {value!r}", field=key + ) + if dtype == pl.Datetime: + try: + return datetime.fromisoformat(value) + except ValueError: + raise FilterError( + f"Datetime ISO 8601 attendu, reçu {value!r}", field=key + ) + return value + + +def _split_key(key: str) -> tuple[str, str] | None: + if "__" not in key: + return None + col, _, op = key.rpartition("__") + if not col or not op: + return None + return col, op + + +def build_where( + args: list[tuple[str, str]], schema: pl.Schema +) -> tuple[str, list, str | None]: + """Parse query params into (where_sql, params, order_by_sql). + + args: list of (key, value) tuples preserving URL order (Werkzeug MultiDict + preserves insertion order on `request.args.items(multi=True)`). + """ + where_parts: list[str] = [] + params: list = [] + order_parts: list[str] = [] + + for key, value in args: + if key in RESERVED_PARAMS: + continue + + parsed = _split_key(key) + if not parsed: + raise FilterError(f"Paramètre non reconnu : {key}", field=key) + col, op = parsed + + if op not in OPERATORS: + raise FilterError(f"Opérateur inconnu : __{op}", field=key) + + if col not in schema: + raise FilterError(f"Colonne inconnue : {col!r}", field=key) + + if op == "sort": + direction = value.lower() + if direction not in ("asc", "desc"): + raise FilterError( + f"Tri attendu 'asc' ou 'desc', reçu {value!r}", field=key + ) + order_parts.append(f'"{col}" {direction.upper()}') + continue + + if op in ("isnull", "isnotnull"): + sql = "IS NULL" if op == "isnull" else "IS NOT NULL" + where_parts.append(f'"{col}" {sql}') + continue + + dtype = schema[col] + + if op in ("in", "notin"): + values = [_coerce(v.strip(), dtype, key) for v in value.split(",")] + placeholders = ",".join(["?"] * len(values)) + sql_op = "IN" if op == "in" else "NOT IN" + where_parts.append(f'"{col}" {sql_op} ({placeholders})') + params.extend(values) + continue + + v = _coerce(value, dtype, key) + + op_sql = { + "exact": "=", + "less": "<=", + "greater": ">=", + "strictly_less": "<", + "strictly_greater": ">", + } + if op in op_sql: + where_parts.append(f'"{col}" {op_sql[op]} ?') + params.append(v) + elif op == "contains": + where_parts.append(f'"{col}" LIKE ?') + params.append(f"%{v}%") + elif op == "notcontains": + where_parts.append(f'"{col}" NOT LIKE ?') + params.append(f"%{v}%") + + where_sql = " AND ".join(where_parts) if where_parts else "TRUE" + order_sql = ", ".join(order_parts) if order_parts else None + return where_sql, params, order_sql +``` + +- [ ] **Step 4: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_filters.py -v` +Expected: PASS (~20 tests). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files src/api/filters.py tests/api/test_filters.py +rtk git add src/api/filters.py tests/api/test_filters.py +rtk git commit -m "API: parser de filtres et génération SQL paramétré (#78)" +``` + +--- + +## Task 5: Décorateur `@require_token` + +**Files:** + +- Create: `src/api/auth.py` +- Create: `tests/api/test_auth.py` + +- [ ] **Step 1: Écrire les tests d'auth** + +Créer `tests/api/test_auth.py` : + +```python +from flask import Flask, g, jsonify + +from src.api import tokens_db +from src.api.auth import require_token + + +def _make_app(): + app = Flask(__name__) + + @app.route("/protected") + @require_token + def protected(): + return jsonify({"token_id": g.token_id}) + + return app + + +def test_missing_header_returns_401(temp_db): + app = _make_app() + resp = app.test_client().get("/protected") + assert resp.status_code == 401 + assert resp.get_json()["message"] == "missing_token" + + +def test_bearer_without_value_returns_401(temp_db): + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": "Bearer "} + ) + assert resp.status_code == 401 + assert resp.get_json()["message"] == "missing_token" + + +def test_invalid_token_returns_401(temp_db): + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": "Bearer decpinfo_unknown"} + ) + assert resp.status_code == 401 + assert resp.get_json()["message"] == "invalid_token" + + +def test_revoked_token_returns_401(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + tokens_db.revoke_token(temp_db, token_id) + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": f"Bearer {token}"} + ) + assert resp.status_code == 401 + assert resp.get_json()["message"] == "revoked_token" + + +def test_valid_token_sets_g_and_calls_view(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": f"Bearer {token}"} + ) + assert resp.status_code == 200 + assert resp.get_json()["token_id"] == token_id +``` + +- [ ] **Step 2: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_auth.py -v` +Expected: FAIL avec `ImportError` sur `src.api.auth`. + +- [ ] **Step 3: Implémenter `auth.py`** + +Créer `src/api/auth.py` : + +```python +import os +from functools import wraps + +from flask import abort, g, jsonify, make_response, request + +from src.api import tokens_db + + +def _abort_401(message: str): + resp = make_response(jsonify({"message": message}), 401) + abort(resp) + + +def require_token(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + _abort_401("missing_token") + token = header[len("Bearer ") :].strip() + if not token: + _abort_401("missing_token") + db_path = os.environ["USERS_DB_PATH"] + row = tokens_db.get_token_by_plaintext(db_path, token) + if row is None: + _abort_401("invalid_token") + if row["revoked_at"] is not None: + _abort_401("revoked_token") + g.token_id = row["id"] + return fn(*args, **kwargs) + + return wrapper +``` + +- [ ] **Step 4: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_auth.py -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files src/api/auth.py tests/api/test_auth.py +rtk git add src/api/auth.py tests/api/test_auth.py +rtk git commit -m "API: décorateur @require_token (#78)" +``` + +--- + +## Task 6: Squelette de blueprint + `/health` + +**Files:** + +- Modify: `src/api/__init__.py` +- Create: `src/api/routes.py` +- Create: `tests/api/test_health.py` + +- [ ] **Step 1: Écrire le test `/health`** + +Créer `tests/api/test_health.py` : + +```python +from flask import Flask + +from src.api import init_api + + +def _make_app(): + app = Flask(__name__) + init_api(app) + return app + + +def test_health_returns_ok_without_auth(): + app = _make_app() + resp = app.test_client().get("/api/v1/health") + assert resp.status_code == 200 + assert resp.get_json() == {"status": "ok"} +``` + +- [ ] **Step 2: Lancer le test, confirmer FAIL** + +Run: `rtk pytest tests/api/test_health.py -v` +Expected: FAIL (`init_api` n'existe pas). + +- [ ] **Step 3: Implémenter `__init__.py` avec blueprint flask-smorest** + +Remplacer le contenu de `src/api/__init__.py` : + +```python +from flask_smorest import Api, Blueprint + +from src.api import routes + + +def init_api(server) -> None: + """Enregistre le blueprint d'API privée sur le serveur Flask.""" + server.config.setdefault("API_TITLE", "decp.info API") + server.config.setdefault("API_VERSION", "v1") + server.config.setdefault("OPENAPI_VERSION", "3.0.3") + server.config.setdefault("OPENAPI_URL_PREFIX", "/api/v1") + server.config.setdefault("OPENAPI_JSON_PATH", "openapi.json") + server.config.setdefault("OPENAPI_SWAGGER_UI_PATH", "swagger") + server.config.setdefault( + "OPENAPI_SWAGGER_UI_URL", + "https://cdn.jsdelivr.net/npm/swagger-ui-dist/", + ) + + api = Api(server) + api.register_blueprint(routes.bp) +``` + +- [ ] **Step 4: Implémenter `routes.py` avec uniquement `/health`** + +Créer `src/api/routes.py` : + +```python +from flask_smorest import Blueprint + +bp = Blueprint( + "api_v1", + "api_v1", + url_prefix="/api/v1", + description="API privée decp.info — accès tabulaire aux marchés publics.", +) + + +@bp.route("/health") +def health(): + """Sonde de santé, sans authentification.""" + return {"status": "ok"} +``` + +- [ ] **Step 5: Lancer le test, confirmer PASS** + +Run: `rtk pytest tests/api/test_health.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/api/__init__.py src/api/routes.py tests/api/test_health.py +rtk git add src/api/__init__.py src/api/routes.py tests/api/test_health.py +rtk git commit -m "API: blueprint flask-smorest + endpoint /health (#78)" +``` + +--- + +## Task 7: Brancher l'API dans l'app Dash + +**Files:** + +- Modify: `src/app.py` + +- [ ] **Step 1: Écrire un test d'intégration** + +Ajouter à la fin de `tests/api/test_health.py` : + +```python +def test_health_via_real_app(): + """Vérifie que init_api est bien branché dans src.app.""" + from src.app import app as dash_app + + resp = dash_app.server.test_client().get("/api/v1/health") + assert resp.status_code == 200 +``` + +- [ ] **Step 2: Lancer le test, confirmer FAIL** + +Run: `rtk pytest tests/api/test_health.py::test_health_via_real_app -v` +Expected: FAIL (404 — l'API n'est pas branchée). + +- [ ] **Step 3: Brancher `init_api` dans `src/app.py`** + +Dans `src/app.py`, après la ligne `cache.init_app(...)` (vers la ligne 52, juste avant la définition de `@app.server.route("/robots.txt")`), ajouter : + +```python +from src.api import init_api + +init_api(app.server) +``` + +L'import en haut du fichier serait plus propre mais romprait l'ordre des dépendances (l'API a besoin que `src.db.conn` soit prêt). Le faire ici garantit l'ordre. + +- [ ] **Step 4: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_health.py -v` +Expected: PASS (2 tests). + +- [ ] **Step 5: Vérifier que l'app Dash démarre toujours** + +Run: `rtk pytest tests/ -x --co -q 2>&1 | tail -5` +Expected: collection sans erreur. + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/app.py tests/api/test_health.py +rtk git add src/app.py tests/api/test_health.py +rtk git commit -m "API: branchement init_api dans l'app Dash (#78)" +``` + +--- + +## Task 8: Endpoint `/schema` + +**Files:** + +- Modify: `src/api/routes.py` +- Create: `tests/api/test_endpoints_schema.py` + +- [ ] **Step 1: Étendre `tests/api/conftest.py` avec une fixture token "vivant"** + +Ajouter à `tests/api/conftest.py` (à la fin) : + +```python +@pytest.fixture +def api_client(monkeypatch, tmp_path): + """Client Flask test avec USERS_DB_PATH éphémère et blueprint API monté.""" + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + from flask import Flask + + from src.api import init_api, tokens_db + + tokens_db.init_schema(db_path) + server = Flask(__name__) + init_api(server) + return server.test_client(), db_path + + +@pytest.fixture +def valid_token_header(api_client): + from src.api import tokens_db + + _, db_path = api_client + token, _ = tokens_db.create_token(db_path, "test-token") + return {"Authorization": f"Bearer {token}"} +``` + +- [ ] **Step 2: Écrire les tests de `/schema`** + +Créer `tests/api/test_endpoints_schema.py` : + +```python +def test_schema_without_token_returns_401(api_client): + client, _ = api_client + resp = client.get("/api/v1/schema") + assert resp.status_code == 401 + + +def test_schema_returns_columns(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/schema", headers=valid_token_header) + assert resp.status_code == 200 + data = resp.get_json() + assert "columns" in data + assert isinstance(data["columns"], list) + assert len(data["columns"]) > 0 + first = data["columns"][0] + assert set(first.keys()) >= {"name", "type"} + # uid doit être présent dans le schéma DECP de test + names = [c["name"] for c in data["columns"]] + assert "uid" in names +``` + +- [ ] **Step 3: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_endpoints_schema.py -v` +Expected: FAIL (route /schema absente → 404). + +- [ ] **Step 4: Implémenter `/schema` dans `routes.py`** + +Ajouter à la fin de `src/api/routes.py` : + +```python +from src.api.auth import require_token +from src.db import schema as duckdb_schema + + +@bp.route("/schema") +@require_token +def schema(): + """Liste des colonnes disponibles dans le dataset DECP.""" + cols = [ + {"name": name, "type": str(dtype)} + for name, dtype in duckdb_schema.items() + ] + return {"columns": cols} +``` + +- [ ] **Step 5: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_endpoints_schema.py -v` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/api/routes.py tests/api/conftest.py tests/api/test_endpoints_schema.py +rtk git add src/api/routes.py tests/api/conftest.py tests/api/test_endpoints_schema.py +rtk git commit -m "API: endpoint /schema (#78)" +``` + +--- + +## Task 9: Endpoint `/data` — pagination de base sans filtres + +**Files:** + +- Modify: `src/api/routes.py` +- Create: `tests/api/test_endpoints_data.py` + +- [ ] **Step 1: Écrire les premiers tests `/data`** + +Créer `tests/api/test_endpoints_data.py` : + +```python +def test_data_without_token_returns_401(api_client): + client, _ = api_client + resp = client.get("/api/v1/data") + assert resp.status_code == 401 + + +def test_data_default_pagination(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert set(body.keys()) >= {"data", "meta", "links"} + assert isinstance(body["data"], list) + assert len(body["data"]) <= 50 # default page_size + assert body["meta"]["page"] == 1 + assert body["meta"]["page_size"] == 50 + assert "total" in body["meta"] + + +def test_data_count_false_omits_total(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?count=false", headers=valid_token_header + ) + assert resp.status_code == 200 + body = resp.get_json() + assert "total" not in body["meta"] + + +def test_data_page_size_max_enforced(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?page_size=5000", headers=valid_token_header + ) + assert resp.status_code == 400 + + +def test_data_page_size_below_min_rejected(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?page_size=0", headers=valid_token_header + ) + assert resp.status_code == 400 + + +def test_data_pagination_links(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?page=1&page_size=1", headers=valid_token_header + ) + body = resp.get_json() + assert body["links"]["prev"] is None + if body["meta"]["total"] > 1: + assert body["links"]["next"] is not None + assert "page=2" in body["links"]["next"] +``` + +- [ ] **Step 2: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_endpoints_data.py -v` +Expected: FAIL (404 sur /data). + +- [ ] **Step 3: Ajouter la route `/data` dans `routes.py`** + +Ajouter à `src/api/routes.py` (après `/schema`) : + +```python +from flask import request +from flask_smorest import abort + +from src.api.filters import FilterError, build_where +from src.db import count_marches, query_marches, schema as duckdb_schema + + +MAX_PAGE_SIZE = 1000 + + +def _parse_pagination(): + try: + page = int(request.args.get("page", "1")) + page_size = int(request.args.get("page_size", "50")) + except ValueError: + abort(400, message="page et page_size doivent être des entiers") + if page < 1: + abort(400, message="page doit être >= 1") + if page_size < 1 or page_size > MAX_PAGE_SIZE: + abort( + 400, + message=f"page_size doit être dans [1, {MAX_PAGE_SIZE}]", + ) + return page, page_size + + +def _parse_columns(): + raw = request.args.get("columns") + if not raw: + return None + cols = [c.strip() for c in raw.split(",") if c.strip()] + unknown = [c for c in cols if c not in duckdb_schema] + if unknown: + abort(400, message=f"Colonnes inconnues : {unknown}") + return cols + + +def _build_links(page, page_size, total): + base = request.path + qs = request.args.to_dict(flat=False) + qs.pop("page", None) + + def url_for(p): + from urllib.parse import urlencode + params = [(k, v) for k, vs in qs.items() for v in vs] + params.append(("page", str(p))) + return f"{base}?{urlencode(params)}" + + prev_url = url_for(page - 1) if page > 1 else None + next_url = None + if total is None or page * page_size < total: + next_url = url_for(page + 1) + return {"prev": prev_url, "next": next_url} + + +@bp.route("/data") +@require_token +def data(): + """Endpoint tabulaire : filtres dynamiques sur les colonnes DECP.""" + page, page_size = _parse_pagination() + columns = _parse_columns() + count = request.args.get("count", "true").lower() != "false" + + try: + where_sql, params, order_sql = build_where( + list(request.args.items(multi=True)), duckdb_schema + ) + except FilterError as e: + abort(400, message=str(e), errors={"field": e.field}) + + df = query_marches( + where_sql=where_sql, + params=params, + columns=columns, + order_by=order_sql, + limit=page_size, + offset=(page - 1) * page_size, + ) + + # JSON ne sérialise pas date/datetime nativement → cast en string ISO + import polars as pl + import polars.selectors as cs + + df_ready = df.with_columns(cs.temporal().cast(pl.String)) + + total = count_marches(where_sql, params) if count else None + meta = {"page": page, "page_size": page_size} + if total is not None: + meta["total"] = total + + return { + "data": df_ready.to_dicts(), + "meta": meta, + "links": _build_links(page, page_size, total), + } +``` + +- [ ] **Step 4: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_endpoints_data.py -v` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files src/api/routes.py tests/api/test_endpoints_data.py +rtk git add src/api/routes.py tests/api/test_endpoints_data.py +rtk git commit -m "API: endpoint /data avec pagination et filtres (#78)" +``` + +--- + +## Task 10: Endpoint `/data` — filtres, tri, columns (intégration) + +**Files:** + +- Modify: `tests/api/test_endpoints_data.py` + +- [ ] **Step 1: Ajouter les tests d'intégration filtres + tri + columns** + +Ajouter à `tests/api/test_endpoints_data.py` : + +```python +def test_data_filter_exact_string(api_client, valid_token_header): + client, _ = api_client + # On choisit une valeur qui existe dans test.parquet : récupère via la 1re ligne + base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json() + assert base["data"], "test.parquet vide ?" + uid = base["data"][0]["uid"] + + resp = client.get( + f"/api/v1/data?uid__exact={uid}", headers=valid_token_header + ) + assert resp.status_code == 200 + body = resp.get_json() + assert all(row["uid"] == uid for row in body["data"]) + + +def test_data_unknown_column_filter_returns_400(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?colonne_inexistante__exact=x", + headers=valid_token_header, + ) + assert resp.status_code == 400 + + +def test_data_columns_selection(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?columns=uid,objet&page_size=3", + headers=valid_token_header, + ) + assert resp.status_code == 200 + body = resp.get_json() + for row in body["data"]: + assert set(row.keys()) == {"uid", "objet"} + + +def test_data_columns_unknown_returns_400(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?columns=uid,foobar", + headers=valid_token_header, + ) + assert resp.status_code == 400 + + +def test_data_sort_desc(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?dateNotification__sort=desc&page_size=5", + headers=valid_token_header, + ) + assert resp.status_code == 200 + body = resp.get_json() + dates = [row["dateNotification"] for row in body["data"] if row.get("dateNotification")] + assert dates == sorted(dates, reverse=True) +``` + +- [ ] **Step 2: Lancer les tests** + +Run: `rtk pytest tests/api/test_endpoints_data.py -v` +Expected: PASS (5 nouveaux tests, 11 au total). Si des FAIL surgissent, c'est généralement lié à l'absence d'une colonne dans `tests/test.parquet` — adapter la valeur de test à ce qui existe. + +- [ ] **Step 3: Commit** + +```bash +rtk git add tests/api/test_endpoints_data.py +rtk git commit -m "API: tests d'intégration filtres/tri/columns (#78)" +``` + +--- + +## Task 11: Suivi de consommation — compteur SQLite asynchrone + +**Files:** + +- Create: `src/api/tracking.py` +- Modify: `src/api/routes.py` +- Create: `tests/api/test_tracking.py` + +- [ ] **Step 1: Écrire le test du worker compteur** + +Créer `tests/api/test_tracking.py` : + +```python +import time + +from src.api import tokens_db, tracking + + +def test_counter_worker_increments_count(temp_db): + _, token_id = tokens_db.create_token(temp_db, "x") + + tracking.start_worker(str(temp_db)) + try: + tracking.enqueue_counter_update(token_id) + tracking.enqueue_counter_update(token_id) + # Laisser le worker drainer la queue + tracking.flush(timeout=2.0) + finally: + tracking.stop_worker() + + rows = tokens_db.list_tokens(temp_db) + assert rows[0]["count_total"] == 2 + assert rows[0]["last_used_at"] is not None + + +def test_after_request_hook_increments_counter_async( + api_client, valid_token_header +): + client, db_path = api_client + # Récupérer le token_id du token créé par la fixture + from src.api import tokens_db, tracking + + rows = tokens_db.list_tokens(db_path) + token_id = rows[0]["id"] + + # Faire une requête (qui doit déclencher l'incrément) + client.get("/api/v1/health") # pas authentifiée → ne compte pas + client.get("/api/v1/schema", headers=valid_token_header) + + tracking.flush(timeout=2.0) + + rows = tokens_db.list_tokens(db_path) + assert rows[0]["count_total"] == 1 +``` + +- [ ] **Step 2: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_tracking.py -v` +Expected: FAIL (`src.api.tracking` n'existe pas). + +- [ ] **Step 3: Implémenter `tracking.py` (compteur seulement, Matomo arrivera Task 12)** + +Créer `src/api/tracking.py` : + +```python +import queue +import threading +from typing import Optional + +from src.api import tokens_db +from src.utils import logger + +_STOP_SENTINEL = object() +_queue: Optional[queue.Queue] = None +_worker_thread: Optional[threading.Thread] = None +_db_path: Optional[str] = None + + +def _worker_loop(q: queue.Queue, db_path: str) -> None: + while True: + item = q.get() + try: + if item is _STOP_SENTINEL: + return + kind, payload = item + if kind == "counter": + token_id = payload + try: + tokens_db.increment_usage(db_path, token_id) + except Exception: # noqa: BLE001 + logger.warning( + "tracking: échec increment_usage token_id=%s", + token_id, + exc_info=True, + ) + finally: + q.task_done() + + +def start_worker(db_path: str) -> None: + global _queue, _worker_thread, _db_path + if _worker_thread is not None and _worker_thread.is_alive(): + return + _db_path = db_path + _queue = queue.Queue() + _worker_thread = threading.Thread( + target=_worker_loop, args=(_queue, db_path), daemon=True + ) + _worker_thread.start() + + +def stop_worker() -> None: + global _worker_thread, _queue + if _worker_thread is None: + return + _queue.put(_STOP_SENTINEL) + _worker_thread.join(timeout=2.0) + _worker_thread = None + _queue = None + + +def enqueue_counter_update(token_id: int) -> None: + if _queue is None: + return # tracking désactivé (tests par ex.) + _queue.put(("counter", token_id)) + + +def flush(timeout: float = 2.0) -> None: + """Attend que la queue soit drainée. Utile en test.""" + if _queue is None: + return + _queue.join() +``` + +- [ ] **Step 4: Brancher le hook `after_request` dans `routes.py`** + +Ajouter à `src/api/routes.py` (avant la définition de `bp.route("/data")`, mais après l'init de `bp`) : + +```python +from flask import g + +from src.api import tracking + + +@bp.after_request +def _track_consumption(response): + token_id = getattr(g, "token_id", None) + if token_id is not None: + tracking.enqueue_counter_update(token_id) + return response +``` + +- [ ] **Step 5: Démarrer le worker au moment du `init_api`** + +Modifier `src/api/__init__.py` pour ajouter dans `init_api` (après `api.register_blueprint(routes.bp)`) : + +```python + import os + + from src.api import tracking + + tracking.start_worker(os.environ["USERS_DB_PATH"]) +``` + +- [ ] **Step 6: Adapter la fixture `api_client` pour démarrer/arrêter le worker proprement** + +Modifier `tests/api/conftest.py`, fixture `api_client` : + +```python +@pytest.fixture +def api_client(monkeypatch, tmp_path): + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + from flask import Flask + + from src.api import init_api, tokens_db, tracking + + tokens_db.init_schema(db_path) + server = Flask(__name__) + init_api(server) + yield server.test_client(), db_path + tracking.stop_worker() +``` + +- [ ] **Step 7: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_tracking.py tests/api/test_endpoints_schema.py tests/api/test_endpoints_data.py -v` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +rtk pre-commit run --files src/api/tracking.py src/api/routes.py src/api/__init__.py tests/api/conftest.py tests/api/test_tracking.py +rtk git add src/api/tracking.py src/api/routes.py src/api/__init__.py tests/api/conftest.py tests/api/test_tracking.py +rtk git commit -m "API: compteur de consommation SQLite asynchrone (#78)" +``` + +--- + +## Task 12: Suivi de consommation — événements Matomo (fire-and-forget) + +**Files:** + +- Modify: `src/api/tracking.py` +- Modify: `src/api/routes.py` +- Modify: `tests/api/test_tracking.py` + +- [ ] **Step 1: Écrire les tests Matomo (avec mock httpx)** + +Ajouter à `tests/api/test_tracking.py` : + +```python +def test_matomo_disabled_skips_call(monkeypatch, api_client, valid_token_header): + monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "false") + client, _ = api_client + called = [] + + from src.api import tracking + + monkeypatch.setattr( + tracking, + "_post_matomo", + lambda **kw: called.append(kw), + ) + client.get("/api/v1/health") + tracking.flush(timeout=2.0) + assert called == [] + + +def test_matomo_enabled_posts_event( + monkeypatch, api_client, valid_token_header +): + monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "true") + monkeypatch.setenv("MATOMO_URL", "https://matomo.example/matomo.php") + monkeypatch.setenv("MATOMO_SITE_ID", "42") + captured = [] + + from src.api import tracking + + monkeypatch.setattr( + tracking, + "_post_matomo", + lambda **kw: captured.append(kw), + ) + + client, _ = api_client + client.get("/api/v1/schema", headers=valid_token_header) + tracking.flush(timeout=2.0) + + assert len(captured) == 1 + call = captured[0] + assert call["params"]["idsite"] == "42" + assert call["params"]["rec"] == "1" + assert "token-" in call["params"]["uid"] + assert call["params"]["dimension2"] == "200" +``` + +- [ ] **Step 2: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_tracking.py -v -k matomo` +Expected: FAIL (`_post_matomo` n'existe pas). + +- [ ] **Step 3: Étendre `tracking.py` avec l'envoi Matomo** + +Ajouter à `src/api/tracking.py` (en haut, après les imports existants) : + +```python +import os +import httpx +``` + +Étendre `_worker_loop` pour gérer un nouveau type `"matomo"` : + +```python +def _worker_loop(q: queue.Queue, db_path: str) -> None: + while True: + item = q.get() + try: + if item is _STOP_SENTINEL: + return + kind, payload = item + if kind == "counter": + token_id = payload + try: + tokens_db.increment_usage(db_path, token_id) + except Exception: # noqa: BLE001 + logger.warning( + "tracking: échec increment_usage token_id=%s", + token_id, + exc_info=True, + ) + elif kind == "matomo": + try: + _post_matomo(**payload) + except Exception: # noqa: BLE001 + logger.warning( + "tracking: échec envoi Matomo", exc_info=True + ) + finally: + q.task_done() + + +def _post_matomo(url: str, params: dict) -> None: + """POST fire-and-forget vers la Tracking API Matomo. Mockable en test.""" + httpx.post(url, data=params, timeout=5.0) + + +def enqueue_matomo_event( + token_id: int, + path: str, + query_string: str, + status_code: int, + user_agent: str, +) -> None: + if _queue is None: + return + if os.getenv("MATOMO_TRACKING_ENABLED", "false").lower() != "true": + return + url = os.getenv("MATOMO_URL") + site_id = os.getenv("MATOMO_SITE_ID") + if not url or not site_id: + return + full_url = f"https://decp.info{path}" + if query_string: + full_url += f"?{query_string}" + params = { + "idsite": site_id, + "rec": "1", + "url": full_url, + "action_name": f"API {path}", + "uid": f"token-{token_id}", + "dimension1": str(token_id), + "dimension2": str(status_code), + "ua": user_agent, + } + _queue.put(("matomo", {"url": url, "params": params})) +``` + +- [ ] **Step 4: Compléter le hook `after_request` pour envoyer aussi à Matomo** + +Modifier `src/api/routes.py`, la fonction `_track_consumption` : + +```python +@bp.after_request +def _track_consumption(response): + token_id = getattr(g, "token_id", None) + if token_id is not None: + tracking.enqueue_counter_update(token_id) + tracking.enqueue_matomo_event( + token_id=token_id, + path=request.path, + query_string=request.query_string.decode("utf-8", errors="replace"), + status_code=response.status_code, + user_agent=request.headers.get("User-Agent", ""), + ) + return response +``` + +- [ ] **Step 5: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_tracking.py -v` +Expected: PASS (tous tests dont les 2 nouveaux Matomo). + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/api/tracking.py src/api/routes.py tests/api/test_tracking.py +rtk git add src/api/tracking.py src/api/routes.py tests/api/test_tracking.py +rtk git commit -m "API: envoi Matomo fire-and-forget en async (#78)" +``` + +--- + +## Task 13: Documentation OpenAPI explicite + Changelog + +**Files:** + +- Modify: `src/api/routes.py` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Ajouter docstrings et statuts OpenAPI sur les routes** + +Modifier les routes dans `src/api/routes.py` pour enrichir Swagger. Exemple sur `/data` (ajouter en docstring) : + +```python +@bp.route("/data") +@require_token +def data(): + """Récupère des marchés publics filtrés. + + Filtres en query string sous la forme `__=`. + + Opérateurs : exact, contains, notcontains, less, greater, + strictly_less, strictly_greater, in, notin, isnull, isnotnull, sort. + + Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000), + columns (csv), count (true|false ; mettre false pour économiser le COUNT(*)). + """ + ... # corps inchangé +``` + +Faire de même pour `/schema` et `/health` (une ligne suffit). + +- [ ] **Step 2: Vérifier manuellement la doc Swagger** + +Run: `rtk python run.py &` puis ouvrir dans un navigateur. +Expected: Swagger UI affiche les 3 endpoints, doc lisible. + +Arrêter le serveur (Ctrl-C ou `kill %1`). + +- [ ] **Step 3: Ajouter l'entrée Changelog** + +Lire `CHANGELOG.md` pour comprendre le format puis ajouter en haut, sous la section "Unreleased" (ou créer cette section) : + +```markdown +## [Unreleased] + +### Ajouté + +- API privée tabulaire `/api/v1/data` (filtres dynamiques, pagination, tri). +- Endpoint `/api/v1/schema` (description du dataset). +- Endpoint `/api/v1/health` (sonde monitoring). +- Documentation interactive Swagger UI à `/api/v1/swagger`. +- CLI d'administration des tokens : `python -m src.api.tokens_cli` (create, list, revoke). +- Suivi de consommation : compteurs SQLite (`api_tokens.count_total`, `last_used_at`) + événements Matomo async. + +### Configuration + +- Nouvelles variables d'environnement : `USERS_DB_PATH`, `MATOMO_URL`, `MATOMO_SITE_ID`, `MATOMO_TRACKING_ENABLED`. +- Création de 2 Custom Dimensions côté Matomo : `dimension1=token_id`, `dimension2=http_status`. +``` + +- [ ] **Step 4: Lancer toute la suite de tests** + +Run: `rtk pytest tests/api/ -v` +Expected: tous les tests PASS. + +Run: `rtk pytest tests/ -x` (suite complète, y compris l'app Dash) +Expected: pas de régression (la suite Dash existante peut être longue, c'est normal). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files src/api/routes.py CHANGELOG.md +rtk git add src/api/routes.py CHANGELOG.md +rtk git commit -m "API: documentation OpenAPI et CHANGELOG (#78)" +``` + +--- + +## Task 14: Mention de l'API sur la page À propos + +**Files:** + +- Modify: `src/pages/a_propos.py` (vérifier le nom exact) + +- [ ] **Step 1: Repérer la page À propos** + +Run: `rtk ls src/pages/` +Identifier le fichier correspondant à `/a-propos` (probablement `a_propos.py` ou `apropos.py`). + +- [ ] **Step 2: Ajouter une section "API"** + +Dans le layout de la page, ajouter une section markdown ou un bloc HTML : + +```python +dcc.Markdown(""" +### API privée + +Une API HTTP est disponible pour accéder aux mêmes données par programme. +Documentation interactive : [Swagger UI](/api/v1/swagger). + +L'accès se fait sur token. Pour en obtenir un, contactez +[colin@maudry.com](mailto:colin@maudry.com). +"""), +``` + +- [ ] **Step 3: Vérifier visuellement** + +Run: `rtk python run.py &` +Ouvrir et confirmer que la section apparaît. +Arrêter le serveur. + +- [ ] **Step 4: Commit** + +```bash +rtk pre-commit run --files src/pages/a_propos.py +rtk git add src/pages/a_propos.py +rtk git commit -m "API: mention sur la page À propos (#78)" +``` + +--- + +## Notes pour l'exécution + +- **Couverture spec** : chaque section du spec a une ou plusieurs tâches associées (deps → Task 1, tokens_db → Task 2, CLI → Task 3, filters → Task 4, auth → Task 5, blueprint+health → Task 6, branchement → Task 7, schema → Task 8, data → Tasks 9-10, tracking SQLite → Task 11, Matomo → Task 12, OpenAPI+changelog → Task 13). +- **`tests/test.parquet`** : peut ne pas contenir toutes les colonnes citées dans les tests (cf. CLAUDE.md). Si un test échoue parce qu'une colonne n'existe pas dans la fixture, l'adapter à une colonne effectivement présente. +- **`src/db.py:127`** ouvre une connexion DuckDB read-only au moment de l'import. Cela peut être lent au premier lancement de la suite de tests (rebuild de la base). C'est normal. +- **Worker tracking** : démarré dans `init_api`, démon. Pas de cleanup nécessaire en prod (mourra avec le process). En test, la fixture `api_client` appelle `stop_worker()` pour propreté. +- **Git flow** : on est déjà sur `feature/78_api`. Tous les commits y vont. Pas de `git push` (cf. CLAUDE.md). From bc3b09e2a9b687a52b6579b596a564b3ce5f3869 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:04:09 +0200 Subject: [PATCH 048/151] =?UTF-8?q?API:=20d=C3=A9pendances=20et=20squelett?= =?UTF-8?q?e=20de=20package=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .template.env | 4 ++++ pyproject.toml | 4 ++++ src/api/__init__.py | 1 + tests/api/__init__.py | 0 4 files changed, 9 insertions(+) create mode 100644 src/api/__init__.py create mode 100644 tests/api/__init__.py diff --git a/.template.env b/.template.env index 6ac9a91..357e16e 100644 --- a/.template.env +++ b/.template.env @@ -24,3 +24,7 @@ TO_EMAIL="to@example.com" # adresse de destination des emails (To) MATOMO_ID_SITE= MATOMO_BASE_URL= MATOMO_TOKEN= + +# API privée +USERS_DB_PATH=./users.sqlite +MATOMO_TRACKING_ENABLED=true diff --git a/pyproject.toml b/pyproject.toml index a1e0f52..a73e0fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ dependencies = [ "flask-caching", "pyarrow>=23.0.1", "flask-cors>=6.0.2", + "flask-smorest>=0.46.0", + "marshmallow>=3.20.0", ] [dependency-groups] @@ -43,5 +45,7 @@ env = [ "DEVELOPMENT=true", "REBUILD_DUCKDB=true", "DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json", + "USERS_DB_PATH=tests/users.test.sqlite", + "MATOMO_TRACKING_ENABLED=false", ] addopts = "-p no:warnings" diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..fff577c --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1 @@ +# decp.info — API privée. init_api(server) sera ajouté Task 6. diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 From 1d190e40866675e0b99569dc4b887166742cfe51 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:05:58 +0200 Subject: [PATCH 049/151] API: ajouter MATOMO_URL et MATOMO_SITE_ID dans .template.env (#78) --- .template.env | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.template.env b/.template.env index 357e16e..aef7230 100644 --- a/.template.env +++ b/.template.env @@ -27,4 +27,6 @@ MATOMO_TOKEN= # API privée USERS_DB_PATH=./users.sqlite +MATOMO_URL=https://analytics.maudry.com/matomo.php +MATOMO_SITE_ID=14 MATOMO_TRACKING_ENABLED=true From ca44b99016c9ef4373db21b1f59bb8c64f18fab3 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:19:50 +0200 Subject: [PATCH 050/151] API: tokens_db SQLite CRUD (#78) --- src/api/tokens_db.py | 94 +++++++++++++++++++++++++++++++++++++ tests/api/conftest.py | 12 +++++ tests/api/test_tokens_db.py | 64 +++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/api/tokens_db.py create mode 100644 tests/api/conftest.py create mode 100644 tests/api/test_tokens_db.py diff --git a/src/api/tokens_db.py b/src/api/tokens_db.py new file mode 100644 index 0000000..76ea1f0 --- /dev/null +++ b/src/api/tokens_db.py @@ -0,0 +1,94 @@ +import hashlib +import secrets +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +TOKEN_PREFIX = "decpinfo_" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + user_id INTEGER, + created_at TEXT NOT NULL, + last_used_at TEXT, + count_total INTEGER NOT NULL DEFAULT 0, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); +""" + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +@contextmanager +def _connect(db_path): + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + +def init_schema(db_path) -> None: + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + with _connect(db_path) as conn: + conn.executescript(SCHEMA) + conn.commit() + + +def create_token(db_path, label: str, user_id: int | None = None) -> tuple[str, int]: + token = TOKEN_PREFIX + secrets.token_hex(32) + with _connect(db_path) as conn: + cur = conn.execute( + "INSERT INTO api_tokens (token_hash, label, user_id, created_at) " + "VALUES (?, ?, ?, ?)", + (_hash(token), label, user_id, _utcnow_iso()), + ) + conn.commit() + return token, cur.lastrowid + + +def get_token_by_plaintext(db_path, token: str) -> dict | None: + with _connect(db_path) as conn: + row = conn.execute( + "SELECT * FROM api_tokens WHERE token_hash = ?", + (_hash(token),), + ).fetchone() + return dict(row) if row else None + + +def revoke_token(db_path, token_id: int) -> None: + with _connect(db_path) as conn: + conn.execute( + "UPDATE api_tokens SET revoked_at = ? WHERE id = ?", + (_utcnow_iso(), token_id), + ) + conn.commit() + + +def increment_usage(db_path, token_id: int) -> None: + with _connect(db_path) as conn: + conn.execute( + "UPDATE api_tokens " + "SET count_total = count_total + 1, last_used_at = ? " + "WHERE id = ?", + (_utcnow_iso(), token_id), + ) + conn.commit() + + +def list_tokens(db_path) -> list[dict]: + with _connect(db_path) as conn: + rows = conn.execute("SELECT * FROM api_tokens ORDER BY id").fetchall() + return [dict(r) for r in rows] diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 0000000..7baca3f --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,12 @@ +import pytest + + +@pytest.fixture +def temp_db(tmp_path, monkeypatch): + """Une SQLite éphémère pour les tests qui modifient la DB.""" + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + from src.api import tokens_db + + tokens_db.init_schema(db_path) + return db_path diff --git a/tests/api/test_tokens_db.py b/tests/api/test_tokens_db.py new file mode 100644 index 0000000..91ca0b0 --- /dev/null +++ b/tests/api/test_tokens_db.py @@ -0,0 +1,64 @@ +import sqlite3 + +from src.api import tokens_db + + +def test_init_schema_creates_table(temp_db): + with sqlite3.connect(str(temp_db)) as conn: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='api_tokens'" + ).fetchall() + assert rows == [("api_tokens",)] + + +def test_create_token_returns_plaintext_and_stores_hash(temp_db): + token, token_id = tokens_db.create_token(temp_db, "test-label") + assert token.startswith("decpinfo_") + assert len(token) == len("decpinfo_") + 64 # 32 octets hex = 64 chars + assert token_id >= 1 + + with sqlite3.connect(str(temp_db)) as conn: + row = conn.execute( + "SELECT token_hash, label, count_total FROM api_tokens WHERE id = ?", + (token_id,), + ).fetchone() + assert row[1] == "test-label" + assert row[2] == 0 + assert row[0] != token # stocké en clair impossible + assert len(row[0]) == 64 # sha256 hex + + +def test_get_token_by_plaintext_returns_row(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + row = tokens_db.get_token_by_plaintext(temp_db, token) + assert row is not None + assert row["id"] == token_id + assert row["label"] == "x" + assert row["revoked_at"] is None + + +def test_get_token_unknown_returns_none(temp_db): + assert tokens_db.get_token_by_plaintext(temp_db, "decpinfo_zzz") is None + + +def test_revoke_token_sets_revoked_at(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + tokens_db.revoke_token(temp_db, token_id) + row = tokens_db.get_token_by_plaintext(temp_db, token) + assert row["revoked_at"] is not None + + +def test_increment_usage_updates_counter_and_timestamp(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + tokens_db.increment_usage(temp_db, token_id) + tokens_db.increment_usage(temp_db, token_id) + row = tokens_db.get_token_by_plaintext(temp_db, token) + assert row["count_total"] == 2 + assert row["last_used_at"] is not None + + +def test_list_tokens_returns_all(temp_db): + tokens_db.create_token(temp_db, "a") + tokens_db.create_token(temp_db, "b") + rows = tokens_db.list_tokens(temp_db) + assert [r["label"] for r in rows] == ["a", "b"] From 4b67cde1478cef6b657e4f2878f4886a26496cc1 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:34:05 +0200 Subject: [PATCH 051/151] API: CLI tokens_cli (create/list/revoke) (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/tokens_cli.py | 57 ++++++++++++++++++++++++++++++++++++ tests/api/test_tokens_cli.py | 33 +++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/api/tokens_cli.py create mode 100644 tests/api/test_tokens_cli.py diff --git a/src/api/tokens_cli.py b/src/api/tokens_cli.py new file mode 100644 index 0000000..287ac06 --- /dev/null +++ b/src/api/tokens_cli.py @@ -0,0 +1,57 @@ +import argparse +import os +import sys + +from src.api import tokens_db + + +def main(argv=None, env=None) -> int: + env = env if env is not None else os.environ + parser = argparse.ArgumentParser(prog="python -m src.api.tokens_cli") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_create = sub.add_parser("create", help="Créer un token API") + p_create.add_argument("--label", required=True) + p_create.add_argument("--user-id", type=int, default=None) + + sub.add_parser("list", help="Lister les tokens") + + p_revoke = sub.add_parser("revoke", help="Révoquer un token") + p_revoke.add_argument("token_id", type=int) + + args = parser.parse_args(argv) + db_path = env["USERS_DB_PATH"] + tokens_db.init_schema(db_path) + + if args.cmd == "create": + token, token_id = tokens_db.create_token(db_path, args.label, args.user_id) + print(f"id={token_id} label={args.label}") + print(f"token (à conserver, ne sera plus affiché) : {token}") + return 0 + + if args.cmd == "list": + rows = tokens_db.list_tokens(db_path) + if not rows: + print("(aucun token)") + return 0 + print( + f"{'id':<4} {'label':<40} {'created_at':<26} {'last_used_at':<26} {'count':<7} revoked" + ) + for r in rows: + print( + f"{r['id']:<4} {r['label']:<40} {r['created_at']:<26} " + f"{(r['last_used_at'] or '-'):<26} {r['count_total']:<7} " + f"{r['revoked_at'] or ''}" + ) + return 0 + + if args.cmd == "revoke": + tokens_db.revoke_token(db_path, args.token_id) + print(f"token id={args.token_id} révoqué") + return 0 + + return 1 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/tests/api/test_tokens_cli.py b/tests/api/test_tokens_cli.py new file mode 100644 index 0000000..24b7d87 --- /dev/null +++ b/tests/api/test_tokens_cli.py @@ -0,0 +1,33 @@ +from src.api import tokens_cli, tokens_db + + +def _run(args, env): + return tokens_cli.main(args, env=env) + + +def test_create_prints_plaintext_token_once(temp_db, capsys): + rc = _run(["create", "--label", "alice"], env={"USERS_DB_PATH": str(temp_db)}) + out = capsys.readouterr().out + assert rc == 0 + assert "decpinfo_" in out + tokens = tokens_db.list_tokens(temp_db) + assert len(tokens) == 1 + assert tokens[0]["label"] == "alice" + + +def test_list_shows_tokens(temp_db, capsys): + tokens_db.create_token(temp_db, "alice") + tokens_db.create_token(temp_db, "bob") + rc = _run(["list"], env={"USERS_DB_PATH": str(temp_db)}) + out = capsys.readouterr().out + assert rc == 0 + assert "alice" in out + assert "bob" in out + + +def test_revoke_sets_revoked_at(temp_db, capsys): + _, token_id = tokens_db.create_token(temp_db, "alice") + rc = _run(["revoke", str(token_id)], env={"USERS_DB_PATH": str(temp_db)}) + assert rc == 0 + tokens = tokens_db.list_tokens(temp_db) + assert tokens[0]["revoked_at"] is not None From a3cfad3d8fcc3cd1cb91f6bb6c248cdd7561142f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:45:55 +0200 Subject: [PATCH 052/151] =?UTF-8?q?API:=20parser=20de=20filtres=20et=20g?= =?UTF-8?q?=C3=A9n=C3=A9ration=20SQL=20param=C3=A9tr=C3=A9=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/api/filters.py | 138 +++++++++++++++++++++++++++++++++++++ tests/api/test_filters.py | 141 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 src/api/filters.py create mode 100644 tests/api/test_filters.py diff --git a/src/api/filters.py b/src/api/filters.py new file mode 100644 index 0000000..573dde8 --- /dev/null +++ b/src/api/filters.py @@ -0,0 +1,138 @@ +from datetime import date, datetime + +import polars as pl + +OPERATORS = { + "exact", + "contains", + "notcontains", + "less", + "greater", + "strictly_less", + "strictly_greater", + "in", + "notin", + "isnull", + "isnotnull", + "sort", +} + +RESERVED_PARAMS = {"page", "page_size", "columns", "count"} + + +class FilterError(ValueError): + def __init__(self, message: str, field: str | None = None): + super().__init__(message) + self.field = field + + +def _coerce(value: str, dtype: pl.DataType, key: str): + if dtype == pl.String: + return value + if dtype.is_integer(): + try: + return int(value) + except ValueError: + raise FilterError(f"Valeur entière attendue, reçu {value!r}", field=key) + if dtype.is_float(): + try: + return float(value) + except ValueError: + raise FilterError(f"Valeur décimale attendue, reçu {value!r}", field=key) + if dtype == pl.Date: + try: + return date.fromisoformat(value) + except ValueError: + raise FilterError( + f"Date ISO 8601 attendue (YYYY-MM-DD), reçu {value!r}", field=key + ) + if dtype == pl.Datetime: + try: + return datetime.fromisoformat(value) + except ValueError: + raise FilterError(f"Datetime ISO 8601 attendu, reçu {value!r}", field=key) + return value + + +def _split_key(key: str) -> tuple[str, str] | None: + if "__" not in key: + return None + col, _, op = key.rpartition("__") + if not col or not op: + return None + return col, op + + +def build_where( + args: list[tuple[str, str]], schema: pl.Schema +) -> tuple[str, list, str | None]: + """Parse query params into (where_sql, params, order_by_sql). + + args: list of (key, value) tuples preserving URL order (Werkzeug MultiDict + preserves insertion order on `request.args.items(multi=True)`). + """ + where_parts: list[str] = [] + params: list = [] + order_parts: list[str] = [] + + for key, value in args: + if key in RESERVED_PARAMS: + continue + + parsed = _split_key(key) + if not parsed: + raise FilterError(f"Paramètre non reconnu : {key}", field=key) + col, op = parsed + + if op not in OPERATORS: + raise FilterError(f"Opérateur inconnu : __{op}", field=key) + + if col not in schema: + raise FilterError(f"Colonne inconnue : {col!r}", field=key) + + if op == "sort": + direction = value.lower() + if direction not in ("asc", "desc"): + raise FilterError( + f"Tri attendu 'asc' ou 'desc', reçu {value!r}", field=key + ) + order_parts.append(f'"{col}" {direction.upper()}') + continue + + if op in ("isnull", "isnotnull"): + sql = "IS NULL" if op == "isnull" else "IS NOT NULL" + where_parts.append(f'"{col}" {sql}') + continue + + dtype = schema[col] + + if op in ("in", "notin"): + values = [_coerce(v.strip(), dtype, key) for v in value.split(",")] + placeholders = ",".join(["?"] * len(values)) + sql_op = "IN" if op == "in" else "NOT IN" + where_parts.append(f'"{col}" {sql_op} ({placeholders})') + params.extend(values) + continue + + v = _coerce(value, dtype, key) + + op_sql = { + "exact": "=", + "less": "<=", + "greater": ">=", + "strictly_less": "<", + "strictly_greater": ">", + } + if op in op_sql: + where_parts.append(f'"{col}" {op_sql[op]} ?') + params.append(v) + elif op == "contains": + where_parts.append(f'"{col}" LIKE ?') + params.append(f"%{v}%") + elif op == "notcontains": + where_parts.append(f'"{col}" NOT LIKE ?') + params.append(f"%{v}%") + + where_sql = " AND ".join(where_parts) if where_parts else "TRUE" + order_sql = ", ".join(order_parts) if order_parts else None + return where_sql, params, order_sql diff --git a/tests/api/test_filters.py b/tests/api/test_filters.py new file mode 100644 index 0000000..b7706fe --- /dev/null +++ b/tests/api/test_filters.py @@ -0,0 +1,141 @@ +import polars as pl +import pytest + +from src.api.filters import FilterError, build_where + +SCHEMA = pl.Schema( + { + "uid": pl.String, + "objet": pl.String, + "montant": pl.Float64, + "annee": pl.Int64, + "dateNotification": pl.Date, + } +) + + +def test_no_filters_returns_true(): + where, params, order = build_where([], SCHEMA) + assert where == "TRUE" + assert params == [] + assert order is None + + +def test_exact_filter(): + where, params, _ = build_where([("uid__exact", "abc")], SCHEMA) + assert where == '"uid" = ?' + assert params == ["abc"] + + +def test_contains_filter_uses_like_wildcards(): + where, params, _ = build_where([("objet__contains", "informatique")], SCHEMA) + assert where == '"objet" LIKE ?' + assert params == ["%informatique%"] + + +def test_notcontains_filter(): + where, params, _ = build_where([("objet__notcontains", "x")], SCHEMA) + assert where == '"objet" NOT LIKE ?' + assert params == ["%x%"] + + +def test_comparison_operators_on_int(): + where, params, _ = build_where([("annee__strictly_greater", "2023")], SCHEMA) + assert where == '"annee" > ?' + assert params == [2024 - 1] # int coercion: 2023 + + +def test_in_filter_splits_on_commas(): + where, params, _ = build_where([("annee__in", "2022,2023,2024")], SCHEMA) + assert where == '"annee" IN (?,?,?)' + assert params == [2022, 2023, 2024] + + +def test_notin_filter(): + where, params, _ = build_where([("annee__notin", "2020,2021")], SCHEMA) + assert where == '"annee" NOT IN (?,?)' + assert params == [2020, 2021] + + +def test_isnull_filter_ignores_value(): + where, params, _ = build_where([("dateNotification__isnull", "anything")], SCHEMA) + assert where == '"dateNotification" IS NULL' + assert params == [] + + +def test_isnotnull_filter(): + where, params, _ = build_where([("dateNotification__isnotnull", "")], SCHEMA) + assert where == '"dateNotification" IS NOT NULL' + + +def test_multiple_filters_joined_by_and(): + where, params, _ = build_where( + [("uid__exact", "a"), ("annee__greater", "2020")], SCHEMA + ) + assert where == '"uid" = ? AND "annee" >= ?' + assert params == ["a", 2020] + + +def test_unknown_column_raises(): + with pytest.raises(FilterError) as exc: + build_where([("foo__exact", "bar")], SCHEMA) + assert "foo" in str(exc.value) + assert exc.value.field == "foo__exact" + + +def test_unknown_operator_raises(): + with pytest.raises(FilterError) as exc: + build_where([("uid__weird", "x")], SCHEMA) + assert "weird" in str(exc.value) + + +def test_bad_int_value_raises(): + with pytest.raises(FilterError): + build_where([("annee__exact", "notanint")], SCHEMA) + + +def test_bad_date_value_raises(): + with pytest.raises(FilterError): + build_where([("dateNotification__exact", "notadate")], SCHEMA) + + +def test_date_iso_coercion(): + where, params, _ = build_where( + [("dateNotification__greater", "2024-01-01")], SCHEMA + ) + from datetime import date + + assert params == [date(2024, 1, 1)] + + +def test_reserved_params_are_ignored(): + where, params, order = build_where( + [ + ("page", "2"), + ("page_size", "100"), + ("columns", "uid"), + ("count", "false"), + ("uid__exact", "z"), + ], + SCHEMA, + ) + assert where == '"uid" = ?' + assert params == ["z"] + + +def test_sort_returns_order_by(): + where, params, order = build_where( + [("annee__sort", "desc"), ("uid__sort", "asc")], SCHEMA + ) + assert where == "TRUE" + assert order == '"annee" DESC, "uid" ASC' + + +def test_sort_invalid_direction_raises(): + with pytest.raises(FilterError): + build_where([("uid__sort", "sideways")], SCHEMA) + + +def test_param_without_operator_raises(): + with pytest.raises(FilterError): + build_where([("uidexact", "x")], SCHEMA) From f12ada2fac81ac2638deecbe4bed9bc9764b4165 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:49:01 +0200 Subject: [PATCH 053/151] =?UTF-8?q?API:=20d=C3=A9corateur=20@require=5Ftok?= =?UTF-8?q?en=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/auth.py | 32 +++++++++++++++++++++++ tests/api/test_auth.py | 59 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/api/auth.py create mode 100644 tests/api/test_auth.py diff --git a/src/api/auth.py b/src/api/auth.py new file mode 100644 index 0000000..1985684 --- /dev/null +++ b/src/api/auth.py @@ -0,0 +1,32 @@ +import os +from functools import wraps + +from flask import abort, g, jsonify, make_response, request + +from src.api import tokens_db + + +def _abort_401(message: str): + resp = make_response(jsonify({"message": message}), 401) + abort(resp) + + +def require_token(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + _abort_401("missing_token") + token = header[len("Bearer ") :].strip() + if not token: + _abort_401("missing_token") + db_path = os.environ["USERS_DB_PATH"] + row = tokens_db.get_token_by_plaintext(db_path, token) + if row is None: + _abort_401("invalid_token") + if row["revoked_at"] is not None: + _abort_401("revoked_token") + g.token_id = row["id"] + return fn(*args, **kwargs) + + return wrapper diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py new file mode 100644 index 0000000..1584bed --- /dev/null +++ b/tests/api/test_auth.py @@ -0,0 +1,59 @@ +from flask import Flask, g, jsonify + +from src.api import tokens_db +from src.api.auth import require_token + + +def _make_app(): + app = Flask(__name__) + + @app.route("/protected") + @require_token + def protected(): + return jsonify({"token_id": g.token_id}) + + return app + + +def test_missing_header_returns_401(temp_db): + app = _make_app() + resp = app.test_client().get("/protected") + assert resp.status_code == 401 + assert resp.get_json()["message"] == "missing_token" + + +def test_bearer_without_value_returns_401(temp_db): + app = _make_app() + resp = app.test_client().get("/protected", headers={"Authorization": "Bearer "}) + assert resp.status_code == 401 + assert resp.get_json()["message"] == "missing_token" + + +def test_invalid_token_returns_401(temp_db): + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": "Bearer decpinfo_unknown"} + ) + assert resp.status_code == 401 + assert resp.get_json()["message"] == "invalid_token" + + +def test_revoked_token_returns_401(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + tokens_db.revoke_token(temp_db, token_id) + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": f"Bearer {token}"} + ) + assert resp.status_code == 401 + assert resp.get_json()["message"] == "revoked_token" + + +def test_valid_token_sets_g_and_calls_view(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": f"Bearer {token}"} + ) + assert resp.status_code == 200 + assert resp.get_json()["token_id"] == token_id From f24d0ba168f98267c11b13a788839703c37985af Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:52:06 +0200 Subject: [PATCH 054/151] API: blueprint flask-smorest + endpoint /health (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/__init__.py | 21 ++++++++++++++++++++- src/api/routes.py | 14 ++++++++++++++ tests/api/test_health.py | 16 ++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/api/routes.py create mode 100644 tests/api/test_health.py diff --git a/src/api/__init__.py b/src/api/__init__.py index fff577c..4dd2e5b 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -1 +1,20 @@ -# decp.info — API privée. init_api(server) sera ajouté Task 6. +from flask_smorest import Api + +from src.api import routes + + +def init_api(server) -> None: + """Enregistre le blueprint d'API privée sur le serveur Flask.""" + server.config.setdefault("API_TITLE", "decp.info API") + server.config.setdefault("API_VERSION", "v1") + server.config.setdefault("OPENAPI_VERSION", "3.0.3") + server.config.setdefault("OPENAPI_URL_PREFIX", "/api/v1") + server.config.setdefault("OPENAPI_JSON_PATH", "openapi.json") + server.config.setdefault("OPENAPI_SWAGGER_UI_PATH", "swagger") + server.config.setdefault( + "OPENAPI_SWAGGER_UI_URL", + "https://cdn.jsdelivr.net/npm/swagger-ui-dist/", + ) + + api = Api(server) + api.register_blueprint(routes.bp) diff --git a/src/api/routes.py b/src/api/routes.py new file mode 100644 index 0000000..759ff1b --- /dev/null +++ b/src/api/routes.py @@ -0,0 +1,14 @@ +from flask_smorest import Blueprint + +bp = Blueprint( + "api_v1", + "api_v1", + url_prefix="/api/v1", + description="API privée decp.info — accès tabulaire aux marchés publics.", +) + + +@bp.route("/health") +def health(): + """Sonde de santé, sans authentification.""" + return {"status": "ok"} diff --git a/tests/api/test_health.py b/tests/api/test_health.py new file mode 100644 index 0000000..3bddbfd --- /dev/null +++ b/tests/api/test_health.py @@ -0,0 +1,16 @@ +from flask import Flask + +from src.api import init_api + + +def _make_app(): + app = Flask(__name__) + init_api(app) + return app + + +def test_health_returns_ok_without_auth(): + app = _make_app() + resp = app.test_client().get("/api/v1/health") + assert resp.status_code == 200 + assert resp.get_json() == {"status": "ok"} From 686218c63946793da9fd506bf0759b663e33fc77 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:07:47 +0200 Subject: [PATCH 055/151] API: branchement init_api dans l'app Dash (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/app.py | 4 ++++ tests/api/test_health.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/app.py b/src/app.py index 40a40d5..2db5ff3 100644 --- a/src/app.py +++ b/src/app.py @@ -51,6 +51,10 @@ cache.init_app( }, ) +from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready first + +init_api(app.server) + # robots.txt @app.server.route("/robots.txt") diff --git a/tests/api/test_health.py b/tests/api/test_health.py index 3bddbfd..7401a1c 100644 --- a/tests/api/test_health.py +++ b/tests/api/test_health.py @@ -14,3 +14,11 @@ def test_health_returns_ok_without_auth(): resp = app.test_client().get("/api/v1/health") assert resp.status_code == 200 assert resp.get_json() == {"status": "ok"} + + +def test_health_via_real_app(): + """Vérifie que init_api est bien branché dans src.app.""" + from src.app import app as dash_app + + resp = dash_app.server.test_client().get("/api/v1/health") + assert resp.status_code == 200 From a05c869b0db876dad914e6401db3e6f0f1c1752d Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:08:30 +0200 Subject: [PATCH 056/151] =?UTF-8?q?API:=20renforcer=20test=20int=C3=A9grat?= =?UTF-8?q?ion=20/health=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/api/test_health.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/api/test_health.py b/tests/api/test_health.py index 7401a1c..ca4f540 100644 --- a/tests/api/test_health.py +++ b/tests/api/test_health.py @@ -22,3 +22,4 @@ def test_health_via_real_app(): resp = dash_app.server.test_client().get("/api/v1/health") assert resp.status_code == 200 + assert resp.get_json() == {"status": "ok"} From 6f4e7f1853dbf6c61e36aa272490e595e34a42e9 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:10:20 +0200 Subject: [PATCH 057/151] API: endpoint /schema (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/routes.py | 11 +++++++++++ tests/api/conftest.py | 24 ++++++++++++++++++++++++ tests/api/test_endpoints_schema.py | 19 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 tests/api/test_endpoints_schema.py diff --git a/src/api/routes.py b/src/api/routes.py index 759ff1b..a4728b3 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,5 +1,8 @@ from flask_smorest import Blueprint +from src.api.auth import require_token +from src.db import schema as duckdb_schema + bp = Blueprint( "api_v1", "api_v1", @@ -12,3 +15,11 @@ bp = Blueprint( def health(): """Sonde de santé, sans authentification.""" return {"status": "ok"} + + +@bp.route("/schema") +@require_token +def schema(): + """Liste des colonnes disponibles dans le dataset DECP.""" + cols = [{"name": name, "type": str(dtype)} for name, dtype in duckdb_schema.items()] + return {"columns": cols} diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 7baca3f..67dc109 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -10,3 +10,27 @@ def temp_db(tmp_path, monkeypatch): tokens_db.init_schema(db_path) return db_path + + +@pytest.fixture +def api_client(monkeypatch, tmp_path): + """Client Flask test avec USERS_DB_PATH éphémère et blueprint API monté.""" + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + from flask import Flask + + from src.api import init_api, tokens_db + + tokens_db.init_schema(db_path) + server = Flask(__name__) + init_api(server) + return server.test_client(), db_path + + +@pytest.fixture +def valid_token_header(api_client): + from src.api import tokens_db + + _, db_path = api_client + token, _ = tokens_db.create_token(db_path, "test-token") + return {"Authorization": f"Bearer {token}"} diff --git a/tests/api/test_endpoints_schema.py b/tests/api/test_endpoints_schema.py new file mode 100644 index 0000000..3d8438e --- /dev/null +++ b/tests/api/test_endpoints_schema.py @@ -0,0 +1,19 @@ +def test_schema_without_token_returns_401(api_client): + client, _ = api_client + resp = client.get("/api/v1/schema") + assert resp.status_code == 401 + + +def test_schema_returns_columns(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/schema", headers=valid_token_header) + assert resp.status_code == 200 + data = resp.get_json() + assert "columns" in data + assert isinstance(data["columns"], list) + assert len(data["columns"]) > 0 + first = data["columns"][0] + assert set(first.keys()) >= {"name", "type"} + # uid doit être présent dans le schéma DECP de test + names = [c["name"] for c in data["columns"]] + assert "uid" in names From 7571b9a9845ab37a89b2819ff35b46edd235afe8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:12:27 +0200 Subject: [PATCH 058/151] API: script de benchmark tests/benchmark_api.py (#78) Co-Authored-By: Claude Sonnet 4.6 --- tests/benchmark_api.py | 208 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/benchmark_api.py diff --git a/tests/benchmark_api.py b/tests/benchmark_api.py new file mode 100644 index 0000000..9431342 --- /dev/null +++ b/tests/benchmark_api.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python +"""Benchmark de l'endpoint /data de l'API privée decp.info. + +Usage : + python tests/benchmark_api.py --url http://localhost:8050/api/v1/data --token decpinfo_xxx + python tests/benchmark_api.py --url https://decp.info/api/v1/data --token decpinfo_xxx --runs 20 + +Le script mesure le temps de réponse de chaque scénario et affiche un tableau +de statistiques (min / médiane / p95 / max). +""" + +import argparse +import statistics +import sys +import time + +import httpx + +SCENARIOS: list[dict] = [ + { + "name": "sans filtre (page 1, 50 résultats)", + "params": {"page": 1, "page_size": 50}, + }, + { + "name": "filtre __exact sur département", + "params": {"acheteur_departement_code__exact": "44", "page_size": 50}, + }, + { + "name": "filtre __contains sur objet", + "params": {"objet__contains": "informatique", "page_size": 50}, + }, + { + "name": "filtre __greater sur date", + "params": {"dateNotification__greater": "2024-01-01", "page_size": 50}, + }, + { + "name": "filtre __strictly_greater sur montant", + "params": {"montant__strictly_greater": "100000", "page_size": 50}, + }, + { + "name": "filtre __in (CPV multiples)", + "params": {"cpv_8__in": "72000000,72200000", "page_size": 50}, + }, + { + "name": "filtre __isnull", + "params": {"montant__isnull": "", "page_size": 50}, + }, + { + "name": "tri desc + colonnes sélectionnées", + "params": { + "dateNotification__sort": "desc", + "columns": "uid,objet,montant,dateNotification", + "page_size": 50, + }, + }, + { + "name": "filtres combinés", + "params": { + "acheteur_departement_code__exact": "75", + "dateNotification__greater": "2023-01-01", + "montant__strictly_greater": "50000", + "dateNotification__sort": "desc", + "page_size": 50, + }, + }, + { + "name": "count=false (économise COUNT(*))", + "params": {"page_size": 50, "count": "false"}, + }, + { + "name": "page 2", + "params": {"page": 2, "page_size": 50}, + }, +] + +COL_NAME = 44 +COL_STATUS = 8 +COL_STAT = 10 + + +def percentile(data: list[float], p: float) -> float: + if not data: + return float("nan") + sorted_data = sorted(data) + k = (len(sorted_data) - 1) * p / 100 + lo, hi = int(k), min(int(k) + 1, len(sorted_data) - 1) + return sorted_data[lo] + (sorted_data[hi] - sorted_data[lo]) * (k - lo) + + +def run_benchmark(base_url: str, token: str | None, runs: int) -> None: + auth_header = {"Authorization": f"Bearer {token}"} if token else {} + results: list[dict] = [] + + print(f"\nBenchmark {base_url}") + print(f"Scénarios : {len(SCENARIOS)} | Répétitions : {runs}\n") + + for scenario in SCENARIOS: + timings: list[float] = [] + last_status = 0 + + for _ in range(runs): + headers = auth_header + url = base_url + params = scenario["params"] + + try: + t0 = time.perf_counter() + resp = httpx.get(url, params=params, headers=headers, timeout=30) + elapsed = (time.perf_counter() - t0) * 1000 + last_status = resp.status_code + timings.append(elapsed) + except httpx.RequestError as exc: + print(f" ERREUR réseau : {exc}") + last_status = 0 + break + + if timings: + results.append( + { + "name": scenario["name"], + "status": last_status, + "min": min(timings), + "median": percentile(timings, 50), + "p95": percentile(timings, 95), + "max": max(timings), + "mean": statistics.mean(timings), + "runs": len(timings), + } + ) + status_str = f"[{last_status}]" + print( + f" {scenario['name'][:COL_NAME]:<{COL_NAME}}" + f" {status_str:<{COL_STATUS}}" + f" médiane {results[-1]['median']:>7.1f} ms" + f" p95 {results[-1]['p95']:>7.1f} ms" + ) + else: + print(f" {scenario['name'][:COL_NAME]:<{COL_NAME}} ÉCHEC") + + _print_summary(results) + + +def _print_summary(results: list[dict]) -> None: + if not results: + return + + sep = "-" * (COL_NAME + COL_STATUS + 4 * (COL_STAT + 3) + 6) + header = ( + f"\n{'Scénario':<{COL_NAME}} {'Status':<{COL_STATUS}}" + f" {'Min (ms)':>{COL_STAT}}" + f" {'Médiane (ms)':>{COL_STAT}}" + f" {'P95 (ms)':>{COL_STAT}}" + f" {'Max (ms)':>{COL_STAT}}" + ) + print(f"\n{'=' * len(sep)}") + print("RÉSUMÉ") + print(f"{'=' * len(sep)}") + print(header) + print(sep) + + for r in results: + status_str = f"[{r['status']}]" + print( + f"{r['name'][:COL_NAME]:<{COL_NAME}}" + f" {status_str:<{COL_STATUS}}" + f" {r['min']:>{COL_STAT}.1f}" + f" {r['median']:>{COL_STAT}.1f}" + f" {r['p95']:>{COL_STAT}.1f}" + f" {r['max']:>{COL_STAT}.1f}" + ) + + print(sep) + medians = [r["median"] for r in results] + print( + f"\nMédiane globale : {statistics.mean(medians):.1f} ms" + f" | Pire p95 : {max(r['p95'] for r in results):.1f} ms" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Benchmark de l'API privée decp.info") + parser.add_argument( + "--url", + default="http://localhost:8050/api/v1/data", + help="URL complète de l'endpoint /data (défaut : http://localhost:8050/api/v1/data)", + ) + parser.add_argument( + "--token", + default=None, + help="Token Bearer API (format : decpinfo_xxxxx) — omis si l'API n'exige pas d'auth", + ) + parser.add_argument( + "--runs", + type=int, + default=5, + help="Nombre de répétitions par scénario (défaut : 5)", + ) + args = parser.parse_args() + + if args.runs < 1: + print("--runs doit être ≥ 1", file=sys.stderr) + sys.exit(1) + + run_benchmark(args.url, args.token, args.runs) + + +if __name__ == "__main__": + main() From 6c591aeb08d4b5a646fb708728ec119746599566 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:15:19 +0200 Subject: [PATCH 059/151] API: endpoint /data avec pagination et filtres (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/routes.py | 95 +++++++++++++++++++++++++++++++- tests/api/test_endpoints_data.py | 47 ++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 tests/api/test_endpoints_data.py diff --git a/src/api/routes.py b/src/api/routes.py index a4728b3..0d5d4a8 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,6 +1,9 @@ -from flask_smorest import Blueprint +from flask import request +from flask_smorest import Blueprint, abort from src.api.auth import require_token +from src.api.filters import FilterError, build_where +from src.db import count_marches, query_marches from src.db import schema as duckdb_schema bp = Blueprint( @@ -10,6 +13,54 @@ bp = Blueprint( description="API privée decp.info — accès tabulaire aux marchés publics.", ) +MAX_PAGE_SIZE = 1000 + + +def _parse_pagination(): + try: + page = int(request.args.get("page", "1")) + page_size = int(request.args.get("page_size", "50")) + except ValueError: + abort(400, message="page et page_size doivent être des entiers") + if page < 1: + abort(400, message="page doit être >= 1") + if page_size < 1 or page_size > MAX_PAGE_SIZE: + abort( + 400, + message=f"page_size doit être dans [1, {MAX_PAGE_SIZE}]", + ) + return page, page_size + + +def _parse_columns(): + raw = request.args.get("columns") + if not raw: + return None + cols = [c.strip() for c in raw.split(",") if c.strip()] + unknown = [c for c in cols if c not in duckdb_schema] + if unknown: + abort(400, message=f"Colonnes inconnues : {unknown}") + return cols + + +def _build_links(page, page_size, total): + base = request.path + qs = request.args.to_dict(flat=False) + qs.pop("page", None) + + def url_for(p): + from urllib.parse import urlencode + + params = [(k, v) for k, vs in qs.items() for v in vs] + params.append(("page", str(p))) + return f"{base}?{urlencode(params)}" + + prev_url = url_for(page - 1) if page > 1 else None + next_url = None + if total is None or page * page_size < total: + next_url = url_for(page + 1) + return {"prev": prev_url, "next": next_url} + @bp.route("/health") def health(): @@ -23,3 +74,45 @@ def schema(): """Liste des colonnes disponibles dans le dataset DECP.""" cols = [{"name": name, "type": str(dtype)} for name, dtype in duckdb_schema.items()] return {"columns": cols} + + +@bp.route("/data") +@require_token +def data(): + """Endpoint tabulaire : filtres dynamiques sur les colonnes DECP.""" + import polars as pl + import polars.selectors as cs + + page, page_size = _parse_pagination() + columns = _parse_columns() + count = request.args.get("count", "true").lower() != "false" + + try: + where_sql, params, order_sql = build_where( + list(request.args.items(multi=True)), duckdb_schema + ) + except FilterError as e: + abort(400, message=str(e), errors={"field": e.field}) + + df = query_marches( + where_sql=where_sql, + params=params, + columns=columns, + order_by=order_sql, + limit=page_size, + offset=(page - 1) * page_size, + ) + + # JSON ne sérialise pas date/datetime nativement → cast en string ISO + df_ready = df.with_columns(cs.temporal().cast(pl.String)) + + total = count_marches(where_sql, params) if count else None + meta = {"page": page, "page_size": page_size} + if total is not None: + meta["total"] = total + + return { + "data": df_ready.to_dicts(), + "meta": meta, + "links": _build_links(page, page_size, total), + } diff --git a/tests/api/test_endpoints_data.py b/tests/api/test_endpoints_data.py new file mode 100644 index 0000000..b45e0c7 --- /dev/null +++ b/tests/api/test_endpoints_data.py @@ -0,0 +1,47 @@ +def test_data_without_token_returns_401(api_client): + client, _ = api_client + resp = client.get("/api/v1/data") + assert resp.status_code == 401 + + +def test_data_default_pagination(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert set(body.keys()) >= {"data", "meta", "links"} + assert isinstance(body["data"], list) + assert len(body["data"]) <= 50 # default page_size + assert body["meta"]["page"] == 1 + assert body["meta"]["page_size"] == 50 + assert "total" in body["meta"] + + +def test_data_count_false_omits_total(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data?count=false", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert "total" not in body["meta"] + + +def test_data_page_size_max_enforced(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data?page_size=5000", headers=valid_token_header) + assert resp.status_code == 400 + + +def test_data_page_size_below_min_rejected(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data?page_size=0", headers=valid_token_header) + assert resp.status_code == 400 + + +def test_data_pagination_links(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data?page=1&page_size=1", headers=valid_token_header) + body = resp.get_json() + assert body["links"]["prev"] is None + if body["meta"]["total"] > 1: + assert body["links"]["next"] is not None + assert "page=2" in body["links"]["next"] From 6d2a95d775d63c6b7ebf07a1903bc226a7f1b027 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:16:27 +0200 Subject: [PATCH 060/151] =?UTF-8?q?API:=20retirer=20benchmark=20non=20dema?= =?UTF-8?q?nd=C3=A9=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/benchmark_api.py | 208 ----------------------------------------- 1 file changed, 208 deletions(-) delete mode 100644 tests/benchmark_api.py diff --git a/tests/benchmark_api.py b/tests/benchmark_api.py deleted file mode 100644 index 9431342..0000000 --- a/tests/benchmark_api.py +++ /dev/null @@ -1,208 +0,0 @@ -#!/usr/bin/env python -"""Benchmark de l'endpoint /data de l'API privée decp.info. - -Usage : - python tests/benchmark_api.py --url http://localhost:8050/api/v1/data --token decpinfo_xxx - python tests/benchmark_api.py --url https://decp.info/api/v1/data --token decpinfo_xxx --runs 20 - -Le script mesure le temps de réponse de chaque scénario et affiche un tableau -de statistiques (min / médiane / p95 / max). -""" - -import argparse -import statistics -import sys -import time - -import httpx - -SCENARIOS: list[dict] = [ - { - "name": "sans filtre (page 1, 50 résultats)", - "params": {"page": 1, "page_size": 50}, - }, - { - "name": "filtre __exact sur département", - "params": {"acheteur_departement_code__exact": "44", "page_size": 50}, - }, - { - "name": "filtre __contains sur objet", - "params": {"objet__contains": "informatique", "page_size": 50}, - }, - { - "name": "filtre __greater sur date", - "params": {"dateNotification__greater": "2024-01-01", "page_size": 50}, - }, - { - "name": "filtre __strictly_greater sur montant", - "params": {"montant__strictly_greater": "100000", "page_size": 50}, - }, - { - "name": "filtre __in (CPV multiples)", - "params": {"cpv_8__in": "72000000,72200000", "page_size": 50}, - }, - { - "name": "filtre __isnull", - "params": {"montant__isnull": "", "page_size": 50}, - }, - { - "name": "tri desc + colonnes sélectionnées", - "params": { - "dateNotification__sort": "desc", - "columns": "uid,objet,montant,dateNotification", - "page_size": 50, - }, - }, - { - "name": "filtres combinés", - "params": { - "acheteur_departement_code__exact": "75", - "dateNotification__greater": "2023-01-01", - "montant__strictly_greater": "50000", - "dateNotification__sort": "desc", - "page_size": 50, - }, - }, - { - "name": "count=false (économise COUNT(*))", - "params": {"page_size": 50, "count": "false"}, - }, - { - "name": "page 2", - "params": {"page": 2, "page_size": 50}, - }, -] - -COL_NAME = 44 -COL_STATUS = 8 -COL_STAT = 10 - - -def percentile(data: list[float], p: float) -> float: - if not data: - return float("nan") - sorted_data = sorted(data) - k = (len(sorted_data) - 1) * p / 100 - lo, hi = int(k), min(int(k) + 1, len(sorted_data) - 1) - return sorted_data[lo] + (sorted_data[hi] - sorted_data[lo]) * (k - lo) - - -def run_benchmark(base_url: str, token: str | None, runs: int) -> None: - auth_header = {"Authorization": f"Bearer {token}"} if token else {} - results: list[dict] = [] - - print(f"\nBenchmark {base_url}") - print(f"Scénarios : {len(SCENARIOS)} | Répétitions : {runs}\n") - - for scenario in SCENARIOS: - timings: list[float] = [] - last_status = 0 - - for _ in range(runs): - headers = auth_header - url = base_url - params = scenario["params"] - - try: - t0 = time.perf_counter() - resp = httpx.get(url, params=params, headers=headers, timeout=30) - elapsed = (time.perf_counter() - t0) * 1000 - last_status = resp.status_code - timings.append(elapsed) - except httpx.RequestError as exc: - print(f" ERREUR réseau : {exc}") - last_status = 0 - break - - if timings: - results.append( - { - "name": scenario["name"], - "status": last_status, - "min": min(timings), - "median": percentile(timings, 50), - "p95": percentile(timings, 95), - "max": max(timings), - "mean": statistics.mean(timings), - "runs": len(timings), - } - ) - status_str = f"[{last_status}]" - print( - f" {scenario['name'][:COL_NAME]:<{COL_NAME}}" - f" {status_str:<{COL_STATUS}}" - f" médiane {results[-1]['median']:>7.1f} ms" - f" p95 {results[-1]['p95']:>7.1f} ms" - ) - else: - print(f" {scenario['name'][:COL_NAME]:<{COL_NAME}} ÉCHEC") - - _print_summary(results) - - -def _print_summary(results: list[dict]) -> None: - if not results: - return - - sep = "-" * (COL_NAME + COL_STATUS + 4 * (COL_STAT + 3) + 6) - header = ( - f"\n{'Scénario':<{COL_NAME}} {'Status':<{COL_STATUS}}" - f" {'Min (ms)':>{COL_STAT}}" - f" {'Médiane (ms)':>{COL_STAT}}" - f" {'P95 (ms)':>{COL_STAT}}" - f" {'Max (ms)':>{COL_STAT}}" - ) - print(f"\n{'=' * len(sep)}") - print("RÉSUMÉ") - print(f"{'=' * len(sep)}") - print(header) - print(sep) - - for r in results: - status_str = f"[{r['status']}]" - print( - f"{r['name'][:COL_NAME]:<{COL_NAME}}" - f" {status_str:<{COL_STATUS}}" - f" {r['min']:>{COL_STAT}.1f}" - f" {r['median']:>{COL_STAT}.1f}" - f" {r['p95']:>{COL_STAT}.1f}" - f" {r['max']:>{COL_STAT}.1f}" - ) - - print(sep) - medians = [r["median"] for r in results] - print( - f"\nMédiane globale : {statistics.mean(medians):.1f} ms" - f" | Pire p95 : {max(r['p95'] for r in results):.1f} ms" - ) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Benchmark de l'API privée decp.info") - parser.add_argument( - "--url", - default="http://localhost:8050/api/v1/data", - help="URL complète de l'endpoint /data (défaut : http://localhost:8050/api/v1/data)", - ) - parser.add_argument( - "--token", - default=None, - help="Token Bearer API (format : decpinfo_xxxxx) — omis si l'API n'exige pas d'auth", - ) - parser.add_argument( - "--runs", - type=int, - default=5, - help="Nombre de répétitions par scénario (défaut : 5)", - ) - args = parser.parse_args() - - if args.runs < 1: - print("--runs doit être ≥ 1", file=sys.stderr) - sys.exit(1) - - run_benchmark(args.url, args.token, args.runs) - - -if __name__ == "__main__": - main() From 04c8bb8b6dd1863006a610e1dfbc10d71180e63b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:28:18 +0200 Subject: [PATCH 061/151] =?UTF-8?q?API:=20tests=20d'int=C3=A9gration=20fil?= =?UTF-8?q?tres/tri/columns=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/api/test_endpoints_data.py | 57 ++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/api/test_endpoints_data.py b/tests/api/test_endpoints_data.py index b45e0c7..8911062 100644 --- a/tests/api/test_endpoints_data.py +++ b/tests/api/test_endpoints_data.py @@ -45,3 +45,60 @@ def test_data_pagination_links(api_client, valid_token_header): if body["meta"]["total"] > 1: assert body["links"]["next"] is not None assert "page=2" in body["links"]["next"] + + +def test_data_filter_exact_string(api_client, valid_token_header): + client, _ = api_client + # On choisit une valeur qui existe dans test.parquet : récupère via la 1re ligne + base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json() + assert base["data"], "test.parquet vide ?" + uid = base["data"][0]["uid"] + + resp = client.get(f"/api/v1/data?uid__exact={uid}", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert all(row["uid"] == uid for row in body["data"]) + + +def test_data_unknown_column_filter_returns_400(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?colonne_inexistante__exact=x", + headers=valid_token_header, + ) + assert resp.status_code == 400 + + +def test_data_columns_selection(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?columns=uid,objet&page_size=3", + headers=valid_token_header, + ) + assert resp.status_code == 200 + body = resp.get_json() + for row in body["data"]: + assert set(row.keys()) == {"uid", "objet"} + + +def test_data_columns_unknown_returns_400(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?columns=uid,foobar", + headers=valid_token_header, + ) + assert resp.status_code == 400 + + +def test_data_sort_desc(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?dateNotification__sort=desc&page_size=5", + headers=valid_token_header, + ) + assert resp.status_code == 200 + body = resp.get_json() + dates = [ + row["dateNotification"] for row in body["data"] if row.get("dateNotification") + ] + assert dates == sorted(dates, reverse=True) From 912507b1d9b74c28c4f71e636063a55edee2a51c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:31:22 +0200 Subject: [PATCH 062/151] API: compteur de consommation SQLite asynchrone (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/__init__.py | 6 ++++ src/api/routes.py | 11 ++++++- src/api/tracking.py | 67 ++++++++++++++++++++++++++++++++++++++ tests/api/conftest.py | 5 +-- tests/api/test_tracking.py | 36 ++++++++++++++++++++ 5 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 src/api/tracking.py create mode 100644 tests/api/test_tracking.py diff --git a/src/api/__init__.py b/src/api/__init__.py index 4dd2e5b..e68f48b 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -18,3 +18,9 @@ def init_api(server) -> None: api = Api(server) api.register_blueprint(routes.bp) + + import os + + from src.api import tracking + + tracking.start_worker(os.environ["USERS_DB_PATH"]) diff --git a/src/api/routes.py b/src/api/routes.py index 0d5d4a8..cce2167 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,6 +1,7 @@ -from flask import request +from flask import g, request from flask_smorest import Blueprint, abort +from src.api import tracking from src.api.auth import require_token from src.api.filters import FilterError, build_where from src.db import count_marches, query_marches @@ -62,6 +63,14 @@ def _build_links(page, page_size, total): return {"prev": prev_url, "next": next_url} +@bp.after_request +def _track_consumption(response): + token_id = getattr(g, "token_id", None) + if token_id is not None: + tracking.enqueue_counter_update(token_id) + return response + + @bp.route("/health") def health(): """Sonde de santé, sans authentification.""" diff --git a/src/api/tracking.py b/src/api/tracking.py new file mode 100644 index 0000000..00d25c5 --- /dev/null +++ b/src/api/tracking.py @@ -0,0 +1,67 @@ +import queue +import threading +from typing import Optional + +from src.api import tokens_db +from src.utils import logger + +_STOP_SENTINEL = object() +_queue: Optional[queue.Queue] = None +_worker_thread: Optional[threading.Thread] = None +_db_path: Optional[str] = None + + +def _worker_loop(q: queue.Queue, db_path: str) -> None: + while True: + item = q.get() + try: + if item is _STOP_SENTINEL: + return + kind, payload = item + if kind == "counter": + token_id = payload + try: + tokens_db.increment_usage(db_path, token_id) + except Exception: # noqa: BLE001 + logger.warning( + "tracking: échec increment_usage token_id=%s", + token_id, + exc_info=True, + ) + finally: + q.task_done() + + +def start_worker(db_path: str) -> None: + global _queue, _worker_thread, _db_path + if _worker_thread is not None and _worker_thread.is_alive(): + return + _db_path = db_path + _queue = queue.Queue() + _worker_thread = threading.Thread( + target=_worker_loop, args=(_queue, db_path), daemon=True + ) + _worker_thread.start() + + +def stop_worker() -> None: + global _worker_thread, _queue + if _worker_thread is None: + return + _queue.put(_STOP_SENTINEL) + _worker_thread.join(timeout=2.0) + _worker_thread = None + _queue = None + + +def enqueue_counter_update(token_id: int) -> None: + if _queue is None: + return # tracking désactivé (tests par ex.) + _queue.put(("counter", token_id)) + + +def flush(timeout: float = 2.0) -> None: + """Attend que la queue soit drainée. Utile en test.""" + if _queue is None: + return + _queue.join() diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 67dc109..5a83507 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -19,12 +19,13 @@ def api_client(monkeypatch, tmp_path): monkeypatch.setenv("USERS_DB_PATH", str(db_path)) from flask import Flask - from src.api import init_api, tokens_db + from src.api import init_api, tokens_db, tracking tokens_db.init_schema(db_path) server = Flask(__name__) init_api(server) - return server.test_client(), db_path + yield server.test_client(), db_path + tracking.stop_worker() @pytest.fixture diff --git a/tests/api/test_tracking.py b/tests/api/test_tracking.py new file mode 100644 index 0000000..dd0a662 --- /dev/null +++ b/tests/api/test_tracking.py @@ -0,0 +1,36 @@ +from src.api import tokens_db, tracking + + +def test_counter_worker_increments_count(temp_db): + _, token_id = tokens_db.create_token(temp_db, "x") + + tracking.start_worker(str(temp_db)) + try: + tracking.enqueue_counter_update(token_id) + tracking.enqueue_counter_update(token_id) + # Laisser le worker drainer la queue + tracking.flush(timeout=2.0) + finally: + tracking.stop_worker() + + rows = tokens_db.list_tokens(temp_db) + assert rows[0]["count_total"] == 2 + assert rows[0]["last_used_at"] is not None + + +def test_after_request_hook_increments_counter_async(api_client, valid_token_header): + client, db_path = api_client + # Récupérer le token_id du token créé par la fixture + from src.api import tokens_db, tracking + + rows = tokens_db.list_tokens(db_path) + assert len(rows) == 1 # vérification du token créé par la fixture + + # Faire une requête (qui doit déclencher l'incrément) + client.get("/api/v1/health") # pas authentifiée → ne compte pas + client.get("/api/v1/schema", headers=valid_token_header) + + tracking.flush(timeout=2.0) + + rows = tokens_db.list_tokens(db_path) + assert rows[0]["count_total"] == 1 From 985af0fdc771d094f7ccd0664bc6a0419b9061d2 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:34:52 +0200 Subject: [PATCH 063/151] API: envoi Matomo fire-and-forget en async (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/routes.py | 7 +++++ src/api/tracking.py | 59 +++++++++++++++++++++++++++++++++++--- tests/api/test_tracking.py | 43 +++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/api/routes.py b/src/api/routes.py index cce2167..afda3ce 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -68,6 +68,13 @@ def _track_consumption(response): token_id = getattr(g, "token_id", None) if token_id is not None: tracking.enqueue_counter_update(token_id) + tracking.enqueue_matomo_event( + token_id=token_id, + path=request.path, + query_string=request.query_string.decode("utf-8", errors="replace"), + status_code=response.status_code, + user_agent=request.headers.get("User-Agent", ""), + ) return response diff --git a/src/api/tracking.py b/src/api/tracking.py index 00d25c5..c952d6b 100644 --- a/src/api/tracking.py +++ b/src/api/tracking.py @@ -1,14 +1,16 @@ +import os import queue import threading from typing import Optional +import httpx + from src.api import tokens_db from src.utils import logger _STOP_SENTINEL = object() _queue: Optional[queue.Queue] = None _worker_thread: Optional[threading.Thread] = None -_db_path: Optional[str] = None def _worker_loop(q: queue.Queue, db_path: str) -> None: @@ -28,15 +30,55 @@ def _worker_loop(q: queue.Queue, db_path: str) -> None: token_id, exc_info=True, ) + elif kind == "matomo": + try: + _post_matomo(**payload) + except Exception: # noqa: BLE001 + logger.warning("tracking: échec envoi Matomo", exc_info=True) finally: q.task_done() +def _post_matomo(url: str, params: dict) -> None: + """POST fire-and-forget vers la Tracking API Matomo. Mockable en test.""" + httpx.post(url, data=params, timeout=5.0) + + +def enqueue_matomo_event( + token_id: int, + path: str, + query_string: str, + status_code: int, + user_agent: str, +) -> None: + if _queue is None: + return + if os.getenv("MATOMO_TRACKING_ENABLED", "false").lower() != "true": + return + url = os.getenv("MATOMO_URL") + site_id = os.getenv("MATOMO_SITE_ID") + if not url or not site_id: + return + full_url = f"https://decp.info{path}" + if query_string: + full_url += f"?{query_string}" + params = { + "idsite": site_id, + "rec": "1", + "url": full_url, + "action_name": f"API {path}", + "uid": f"token-{token_id}", + "dimension1": str(token_id), + "dimension2": str(status_code), + "ua": user_agent, + } + _queue.put(("matomo", {"url": url, "params": params})) + + def start_worker(db_path: str) -> None: - global _queue, _worker_thread, _db_path + global _queue, _worker_thread if _worker_thread is not None and _worker_thread.is_alive(): return - _db_path = db_path _queue = queue.Queue() _worker_thread = threading.Thread( target=_worker_loop, args=(_queue, db_path), daemon=True @@ -64,4 +106,13 @@ def flush(timeout: float = 2.0) -> None: """Attend que la queue soit drainée. Utile en test.""" if _queue is None: return - _queue.join() + # Use a sentinel approach to properly honour the timeout + done_event = threading.Event() + + def _wait(): + _queue.join() + done_event.set() + + t = threading.Thread(target=_wait, daemon=True) + t.start() + done_event.wait(timeout=timeout) diff --git a/tests/api/test_tracking.py b/tests/api/test_tracking.py index dd0a662..44d9f54 100644 --- a/tests/api/test_tracking.py +++ b/tests/api/test_tracking.py @@ -34,3 +34,46 @@ def test_after_request_hook_increments_counter_async(api_client, valid_token_hea rows = tokens_db.list_tokens(db_path) assert rows[0]["count_total"] == 1 + + +def test_matomo_disabled_skips_call(monkeypatch, api_client, valid_token_header): + monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "false") + client, _ = api_client + + from src.api import tracking + + called = [] + monkeypatch.setattr( + tracking, + "_post_matomo", + lambda **kw: called.append(kw), + ) + client.get("/api/v1/health") + tracking.flush(timeout=2.0) + assert called == [] + + +def test_matomo_enabled_posts_event(monkeypatch, api_client, valid_token_header): + monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "true") + monkeypatch.setenv("MATOMO_URL", "https://matomo.example/matomo.php") + monkeypatch.setenv("MATOMO_SITE_ID", "42") + + from src.api import tracking + + captured = [] + monkeypatch.setattr( + tracking, + "_post_matomo", + lambda **kw: captured.append(kw), + ) + + client, _ = api_client + client.get("/api/v1/schema", headers=valid_token_header) + tracking.flush(timeout=2.0) + + assert len(captured) == 1 + call = captured[0] + assert call["params"]["idsite"] == "42" + assert call["params"]["rec"] == "1" + assert "token-" in call["params"]["uid"] + assert call["params"]["dimension2"] == "200" From c683034e2bf018b42745a9f9c11d5204b0db3e45 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:37:21 +0200 Subject: [PATCH 064/151] API: documentation OpenAPI et CHANGELOG (#78) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 16 ++++++++++++++++ src/api/routes.py | 11 ++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5dbcd..a416ce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## [Unreleased] + +### Ajouté + +- API privée tabulaire `/api/v1/data` (filtres dynamiques, pagination, tri). +- Endpoint `/api/v1/schema` (description du dataset). +- Endpoint `/api/v1/health` (sonde monitoring). +- Documentation interactive Swagger UI à `/api/v1/swagger`. +- CLI d'administration des tokens : `python -m src.api.tokens_cli` (create, list, revoke). +- Suivi de consommation : compteurs SQLite (`api_tokens.count_total`, `last_used_at`) + événements Matomo async. + +### Configuration + +- Nouvelles variables d'environnement : `USERS_DB_PATH`, `MATOMO_URL`, `MATOMO_SITE_ID`, `MATOMO_TRACKING_ENABLED`. +- Création de 2 Custom Dimensions côté Matomo : `dimension1=token_id`, `dimension2=http_status`. + ##### 2.7.7 (11 mai 2026) - Suppression des mentions sur les profils d'acheteur. Omnikles/Safetender publie via l'API DUME et Klekoon ne publie pas, mais c'est peut-être pas le seul, donc je préfère supprimer et refaire un tour. diff --git a/src/api/routes.py b/src/api/routes.py index afda3ce..9ffa0aa 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -95,7 +95,16 @@ def schema(): @bp.route("/data") @require_token def data(): - """Endpoint tabulaire : filtres dynamiques sur les colonnes DECP.""" + """Récupère des marchés publics filtrés. + + Filtres en query string sous la forme `__=`. + + Opérateurs : exact, contains, notcontains, less, greater, + strictly_less, strictly_greater, in, notin, isnull, isnotnull, sort. + + Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000), + columns (csv), count (true|false ; mettre false pour économiser le COUNT(*)). + """ import polars as pl import polars.selectors as cs From 2f0cbada34ca0bdfa8ebea62151625bdd4a20e9c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:45:26 +0200 Subject: [PATCH 065/151] API: corriger flush() et isolation test compteur (#78) --- src/api/tracking.py | 14 +++----------- tests/api/test_tracking.py | 1 + 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/api/tracking.py b/src/api/tracking.py index c952d6b..0762e1b 100644 --- a/src/api/tracking.py +++ b/src/api/tracking.py @@ -104,15 +104,7 @@ def enqueue_counter_update(token_id: int) -> None: def flush(timeout: float = 2.0) -> None: """Attend que la queue soit drainée. Utile en test.""" - if _queue is None: + q = _queue + if q is None: return - # Use a sentinel approach to properly honour the timeout - done_event = threading.Event() - - def _wait(): - _queue.join() - done_event.set() - - t = threading.Thread(target=_wait, daemon=True) - t.start() - done_event.wait(timeout=timeout) + q.join() diff --git a/tests/api/test_tracking.py b/tests/api/test_tracking.py index 44d9f54..36e57a6 100644 --- a/tests/api/test_tracking.py +++ b/tests/api/test_tracking.py @@ -4,6 +4,7 @@ from src.api import tokens_db, tracking def test_counter_worker_increments_count(temp_db): _, token_id = tokens_db.create_token(temp_db, "x") + tracking.stop_worker() # reset any worker left by earlier tests tracking.start_worker(str(temp_db)) try: tracking.enqueue_counter_update(token_id) From 63f51d7b98a0658062c222c81c793aaadd2f2561 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:47:03 +0200 Subject: [PATCH 066/151] =?UTF-8?q?API:=20mention=20sur=20la=20page=20?= =?UTF-8?q?=C3=80=20propos=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/pages/a-propos.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/pages/a-propos.py b/src/pages/a-propos.py index 26d2ba7..2e69d49 100644 --- a/src/pages/a-propos.py +++ b/src/pages/a-propos.py @@ -49,6 +49,16 @@ Vous pouvez consommer les données qui alimentent decp.info - en les téléchargeant [sur data.gouv.fr](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire) (Parquet, CSV), pensez à lire la description du jeu de données - en interrogeant l'[API REST ouverte](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire#user-content-api-rest) +""" + ), + html.H4("API privée", id="api-privee"), + dcc.Markdown( + """ +Une API HTTP est disponible pour accéder aux mêmes données par programme. +Documentation interactive : [Swagger UI](/api/v1/swagger). + +L'accès se fait sur token. Pour en obtenir un, contactez +[colin@maudry.com](mailto:colin@maudry.com). """ ), html.H4("Contact", id="contact"), @@ -149,6 +159,11 @@ J'enregistre également les données suivantes, de manière anonyme, afin de mie href="#donnees-brutes", className="toc-link", ), + html.A( + "API privée", + href="#api-privee", + className="toc-link", + ), html.A( "Contact", href="#contact", className="toc-link" ), From 8dc528791864ebc11ed8ca3c36b9d884cb0fa943 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 18 May 2026 11:47:05 +0200 Subject: [PATCH 067/151] =?UTF-8?q?Ajouts=20=C3=A0=20flask=20#78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .template.env | 9 +++-- src/api/auth.py | 29 ++++++++------- uv.lock | 99 ++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 119 insertions(+), 18 deletions(-) diff --git a/.template.env b/.template.env index aef7230..7cdf125 100644 --- a/.template.env +++ b/.template.env @@ -15,10 +15,10 @@ DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom # Formulaire de contact SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP -LOGIN_PASSWORD="" # mot de passe du serveur -LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP -FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From) -TO_EMAIL="to@example.com" # adresse de destination des emails (To) +LOGIN_PASSWORD="" # mot de passe du serveur +LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP +FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From) +TO_EMAIL="to@example.com" # adresse de destination des emails (To) # Matomo MATOMO_ID_SITE= @@ -26,6 +26,7 @@ MATOMO_BASE_URL= MATOMO_TOKEN= # API privée +DISABLE_API_AUTH="false" USERS_DB_PATH=./users.sqlite MATOMO_URL=https://analytics.maudry.com/matomo.php MATOMO_SITE_ID=14 diff --git a/src/api/auth.py b/src/api/auth.py index 1985684..915d35c 100644 --- a/src/api/auth.py +++ b/src/api/auth.py @@ -5,6 +5,8 @@ from flask import abort, g, jsonify, make_response, request from src.api import tokens_db +API_AUTH_DISABLED = os.getenv("API_AUTH_DISABLED", "False").lower() == "true" + def _abort_401(message: str): resp = make_response(jsonify({"message": message}), 401) @@ -14,19 +16,20 @@ def _abort_401(message: str): def require_token(fn): @wraps(fn) def wrapper(*args, **kwargs): - header = request.headers.get("Authorization", "") - if not header.startswith("Bearer "): - _abort_401("missing_token") - token = header[len("Bearer ") :].strip() - if not token: - _abort_401("missing_token") - db_path = os.environ["USERS_DB_PATH"] - row = tokens_db.get_token_by_plaintext(db_path, token) - if row is None: - _abort_401("invalid_token") - if row["revoked_at"] is not None: - _abort_401("revoked_token") - g.token_id = row["id"] + if not API_AUTH_DISABLED: + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + _abort_401("missing_token") + token = header[len("Bearer ") :].strip() + if not token: + _abort_401("missing_token") + db_path = os.environ["USERS_DB_PATH"] + row = tokens_db.get_token_by_plaintext(db_path, token) + if row is None: + _abort_401("invalid_token") + if row["revoked_at"] is not None: + _abort_401("revoked_token") + g.token_id = row["id"] return fn(*args, **kwargs) return wrapper diff --git a/uv.lock b/uv.lock index 9ac4ecc..0b698dc 100644 --- a/uv.lock +++ b/uv.lock @@ -37,6 +37,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, ] +[[package]] +name = "apispec" +version = "6.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/f1/1f5a9332df3ecd90cc5ab69bc58a4174b8ba2ac1720c4c26b01d20751bf5/apispec-6.10.0.tar.gz", hash = "sha256:0a888555cd4aa5fb7176041be15684154fd8961055e1672e703abf737e8761bf", size = 80631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/88/e149b20246c4689e7d27163e4e3bb8946ef31617cfb3b9c427813483fe5b/apispec-6.10.0-py3-none-any.whl", hash = "sha256:8ff23e0de9a0ceb62ff70047241126315bd17b8d0565a567934c0156f4ddbb43", size = 31313 }, +] + +[package.optional-dependencies] +marshmallow = [ + { name = "marshmallow" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -46,6 +63,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, ] +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/81/eff3184acb1d9dc3ce95a98b6f3c81a49b4be296e664db8e1c2eeabef3d9/backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", size = 23588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/4b/d6b051ca4b3d76f23c2c436a9669f3be616b8cf6461a7e8061c7c4269642/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", size = 27561 }, + { url = "https://files.pythonhosted.org/packages/6d/40/e39b0d471e55eb1b5c7c81edab605c02f71c786d59fb875f0a6f23318747/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", size = 34448 }, + { url = "https://files.pythonhosted.org/packages/f2/28/7a5c87c5561d14f1c9af979231fdf85d8f9fad7a95ff94e56d2205e2520a/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", size = 27093 }, + { url = "https://files.pythonhosted.org/packages/80/ba/f00296c5c4536967c7d1136107fdb91c48404fe769a4a6fd5ab045629af8/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", size = 52836 }, + { url = "https://files.pythonhosted.org/packages/e3/92/bb1da57a069ddd601aee352a87262c7ae93467e66721d5762f59df5021a6/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", size = 52798 }, + { url = "https://files.pythonhosted.org/packages/df/ef/b6cfd355982e817ccdb8d8d109f720cab6e06f900784b034b30efa8fa832/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", size = 52891 }, + { url = "https://files.pythonhosted.org/packages/37/39/b13e3ae8a7c5d88b68a6e9248ffe7066534b0cfe504bf521963e61b6282d/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", size = 52955 }, + { url = "https://files.pythonhosted.org/packages/1e/e4/70cffa3ce1eb4f2ff0c0d6f5d56285aacead6bd3879b27a2ba57ab261172/backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", size = 29323 }, + { url = "https://files.pythonhosted.org/packages/62/f5/5bc92030deadf34c365d908d4533709341fb05d0082db318774fdf1b2bcb/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", size = 27626 }, + { url = "https://files.pythonhosted.org/packages/28/45/5885737d51f81dfcd0911dd5c16b510b249d4c4cf6f4a991176e0358a42a/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", size = 34588 }, + { url = "https://files.pythonhosted.org/packages/bc/6d/bd74de70953f5dd3e768c8fc774af942af0ce9f211e7c38dd478fa7ea910/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", size = 27162 }, + { url = "https://files.pythonhosted.org/packages/47/ba/1d14b097f13cce45b2b35db9898957578b7fcc984e79af3b35189e0d332f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", size = 54482 }, + { url = "https://files.pythonhosted.org/packages/25/e9/a2a7927d053b6fa148b64b5e13ca741ca254c13edca99d8251e9a8a09cfe/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", size = 54362 }, + { url = "https://files.pythonhosted.org/packages/c1/99/394fb5e80131a7d58c49b89e78a61733a9994885804a0bb582416dd10c6f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", size = 54162 }, + { url = "https://files.pythonhosted.org/packages/88/25/1940369de573c752889646d70b3fe8645e77b9e17984e72a554b9b51ffc4/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", size = 54118 }, + { url = "https://files.pythonhosted.org/packages/b7/46/f275bf6c61683414acaf42b2df7286d68cfef03e98b45c168323d7707778/backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", size = 29329 }, + { url = "https://files.pythonhosted.org/packages/a2/0f/69bbdde2e1e57c09b5f01788804c50e68b29890aada999f2b1a40519def9/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", size = 27630 }, + { url = "https://files.pythonhosted.org/packages/d5/1d/1c84a50c673c87518b1adfeafcfd149991ed1f7aedc45d6e5eac2f7d19d7/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", size = 34707 }, + { url = "https://files.pythonhosted.org/packages/71/44/27eae384e7e045cda83f70b551d04b4a0b294f9822d32dea1cbf1592de59/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", size = 27280 }, + { url = "https://files.pythonhosted.org/packages/a7/7a/a4075187eb6bbb1ff6beb7229db5f66d1070e6968abeb61e056fa51afa5e/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", size = 55094 }, + { url = "https://files.pythonhosted.org/packages/71/03/3fced4230c10af14aacadc195fe58e2ced91d011217b450c2e16a09a98c8/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32", size = 55605 }, + { url = "https://files.pythonhosted.org/packages/f6/0a/4b34a838c57bd16d3e5861ab963845e73a1041034651f7459e9935289cfd/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", size = 55353 }, + { url = "https://files.pythonhosted.org/packages/d9/68/07d13c6e98e1cad85606a876367ede2de46af859833a1da12c413c201d78/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", size = 55298 }, + { url = "https://files.pythonhosted.org/packages/60/33/45b4d5311f42360f9b900dea53ab2bb20a3d61d7f9b7c37ddfcb3962f86f/backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", size = 29375 }, + { url = "https://files.pythonhosted.org/packages/be/03/7eaa9f9bf290395d57fd30d7f1f2f9dff60c06a31c237dc2beb477e8f899/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", size = 28980 }, + { url = "https://files.pythonhosted.org/packages/47/80/a0ecf33446c7349e79f54cc532933780341d20cff0ee12b5bfdcaa47067e/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", size = 28449 }, +] + [[package]] name = "backports-zstd" version = "1.3.0" @@ -760,7 +811,7 @@ wheels = [ [[package]] name = "decp-info" -version = "2.7.6" +version = "2.7.7" source = { virtual = "." } dependencies = [ { name = "dash", extra = ["compress"] }, @@ -772,8 +823,10 @@ dependencies = [ { name = "duckdb" }, { name = "flask-caching" }, { name = "flask-cors" }, + { name = "flask-smorest" }, { name = "gunicorn" }, { name = "httpx" }, + { name = "marshmallow" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pandas", version = "3.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "plotly", extra = ["express"] }, @@ -805,8 +858,10 @@ requires-dist = [ { name = "duckdb" }, { name = "flask-caching" }, { name = "flask-cors", specifier = ">=6.0.2" }, + { name = "flask-smorest", specifier = ">=0.46.0" }, { name = "gunicorn" }, { name = "httpx" }, + { name = "marshmallow", specifier = ">=3.20.0" }, { name = "pandas" }, { name = "plotly", extras = ["express"] }, { name = "polars" }, @@ -993,6 +1048,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257 }, ] +[[package]] +name = "flask-smorest" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apispec", extra = ["marshmallow"] }, + { name = "flask" }, + { name = "marshmallow" }, + { name = "webargs" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/b0/5edd5f3231ea26b52e786588c45d786df26929d2be8fec634bcb871b2a3f/flask_smorest-0.47.0.tar.gz", hash = "sha256:a415ce7db3d7e63dd854eb16d59047686dbf5d31c4913e245fa5657f88e7788d", size = 77600 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/19/53cc4e4d4a3d61362053acd359a425f3e9f2c792698dd4dbb5f5441b5f97/flask_smorest-0.47.0-py3-none-any.whl", hash = "sha256:9329d8697f8710ea6446e9b7876a4f58af2dbc02116d10ebac6f24a7c0ebdea9", size = 32379 }, +] + [[package]] name = "geobuf" version = "2.0.1" @@ -1332,6 +1403,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, ] +[[package]] +name = "marshmallow" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-datetime-fromisoformat", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/7e/1dbd4096eb7c148cd2841841916f78820bb85a4d80a0c25c02d30815a7fb/marshmallow-4.3.0.tar.gz", hash = "sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880", size = 224485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/e0/ff24e25218bb59eb6290a530cea40651b14068b6e3659b20f9c175179632/marshmallow-4.3.0-py3-none-any.whl", hash = "sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46", size = 49148 }, +] + [[package]] name = "more-itertools" version = "10.8.0" @@ -2483,6 +2567,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232 }, ] +[[package]] +name = "webargs" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/64/17afc4e6f47eef154a553c6e56adcc9f1ac3003305c7df978d11aa62937e/webargs-8.7.1.tar.gz", hash = "sha256:799bf9039c76c23fd8dc1951107a75a9e561203c15d6ae8f89c1e46e234636c1", size = 97351 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/ef/b0d17f3943429358184449771b592e0e1d33bbeaa6ed326434a95eac187b/webargs-8.7.1-py3-none-any.whl", hash = "sha256:a184aed9d2509e6e14ab99ee3e9dc3a614c7070affe94cd4dfdb0d002e0a6e5f", size = 32500 }, +] + [[package]] name = "webdriver-manager" version = "4.0.2" From 7d4df0e6adab2a063134659bb81e3604d7bd7e87 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 18 May 2026 12:16:03 +0200 Subject: [PATCH 068/151] =?UTF-8?q?R=C3=A9cup=C3=A9ration=20de=20sch=C3=A9?= =?UTF-8?q?ma=20plus=20robuste=20et=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .template.env | 9 +++++---- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/utils/data.py | 29 ++++++++++++++++++++--------- uv.lock | 2 +- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/.template.env b/.template.env index 6ac9a91..98967e9 100644 --- a/.template.env +++ b/.template.env @@ -9,16 +9,17 @@ ANNOUNCEMENTS= # Chemin vers le schéma de données DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a +DATA_SCHEMA_PATH_LOCAL=../schema.json # Colonnes masquées par défaut DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom, titulaire_id, dateNotification, dureeMois, acheteur_departement_code, sourceDataset" # Formulaire de contact SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP -LOGIN_PASSWORD="" # mot de passe du serveur -LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP -FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From) -TO_EMAIL="to@example.com" # adresse de destination des emails (To) +LOGIN_PASSWORD="" # mot de passe du serveur +LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP +FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From) +TO_EMAIL="to@example.com" # adresse de destination des emails (To) # Matomo MATOMO_ID_SITE= diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5dbcd..6f96906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +##### 2.7.8 (18 mai 2026) + +- Récupération du schéma de données plus robuste, ne pas dépendre de data.gouv.fr + ##### 2.7.7 (11 mai 2026) - Suppression des mentions sur les profils d'acheteur. Omnikles/Safetender publie via l'API DUME et Klekoon ne publie pas, mais c'est peut-être pas le seul, donc je préfère supprimer et refaire un tour. diff --git a/pyproject.toml b/pyproject.toml index a1e0f52..362752d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "decp.info" description = "Interface d'exploration et d'analyse des marchés publics français." -version = "2.7.7" +version = "2.7.8" requires-python = ">= 3.10" authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }] dependencies = [ diff --git a/src/utils/data.py b/src/utils/data.py index eb9c170..0c792a5 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -2,7 +2,9 @@ import json import logging import os from collections import OrderedDict +from pathlib import Path +import httpx import polars as pl from httpx import HTTPError, get @@ -63,16 +65,25 @@ def get_departement_region(code_postal): def get_data_schema() -> dict: # Récupération du schéma des données tabulaires - path = os.getenv("DATA_SCHEMA_PATH") - if path.startswith("http"): - original_schema: dict = get( - os.getenv("DATA_SCHEMA_PATH"), follow_redirects=True - ).json() - elif os.path.exists(path): - with open(path) as f: + url = os.getenv("DATA_SCHEMA_PATH") + local_path = Path(os.getenv("DATA_SCHEMA_LOCAL", "")) + + original_schema = {} + if url: + try: + original_schema: dict = get(url, follow_redirects=True).json() + except ( + httpx.ReadTimeout, + httpx.ReadError, + httpx.ConnectError, + httpx.ConnectTimeout, + ): + logger.error(f"Erreur HTTP lors de la récupération du schéma ({url})") + + if os.path.exists(local_path) and original_schema == {}: + with open(local_path) as f: original_schema: dict = json.load(f) - else: - raise Exception(f"Chemin vers le schéma invalide: {path}") + logger.info(f"Utilisation du schéma local ({local_path})") new_schema = OrderedDict() diff --git a/uv.lock b/uv.lock index 9ac4ecc..0742454 100644 --- a/uv.lock +++ b/uv.lock @@ -760,7 +760,7 @@ wheels = [ [[package]] name = "decp-info" -version = "2.7.6" +version = "2.7.7" source = { virtual = "." } dependencies = [ { name = "dash", extra = ["compress"] }, From ad7e3e5b1ff85979a4b9cbbc544a178fb1fc51d2 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 18 May 2026 12:16:03 +0200 Subject: [PATCH 069/151] =?UTF-8?q?R=C3=A9cup=C3=A9ration=20de=20sch=C3=A9?= =?UTF-8?q?ma=20plus=20robuste=20et=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .template.env | 9 +++++---- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/utils/data.py | 29 ++++++++++++++++++++--------- uv.lock | 2 +- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/.template.env b/.template.env index 6ac9a91..98967e9 100644 --- a/.template.env +++ b/.template.env @@ -9,16 +9,17 @@ ANNOUNCEMENTS= # Chemin vers le schéma de données DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a +DATA_SCHEMA_PATH_LOCAL=../schema.json # Colonnes masquées par défaut DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom, titulaire_id, dateNotification, dureeMois, acheteur_departement_code, sourceDataset" # Formulaire de contact SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP -LOGIN_PASSWORD="" # mot de passe du serveur -LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP -FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From) -TO_EMAIL="to@example.com" # adresse de destination des emails (To) +LOGIN_PASSWORD="" # mot de passe du serveur +LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP +FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From) +TO_EMAIL="to@example.com" # adresse de destination des emails (To) # Matomo MATOMO_ID_SITE= diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5dbcd..6f96906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +##### 2.7.8 (18 mai 2026) + +- Récupération du schéma de données plus robuste, ne pas dépendre de data.gouv.fr + ##### 2.7.7 (11 mai 2026) - Suppression des mentions sur les profils d'acheteur. Omnikles/Safetender publie via l'API DUME et Klekoon ne publie pas, mais c'est peut-être pas le seul, donc je préfère supprimer et refaire un tour. diff --git a/pyproject.toml b/pyproject.toml index a1e0f52..362752d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "decp.info" description = "Interface d'exploration et d'analyse des marchés publics français." -version = "2.7.7" +version = "2.7.8" requires-python = ">= 3.10" authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }] dependencies = [ diff --git a/src/utils/data.py b/src/utils/data.py index eb9c170..0c792a5 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -2,7 +2,9 @@ import json import logging import os from collections import OrderedDict +from pathlib import Path +import httpx import polars as pl from httpx import HTTPError, get @@ -63,16 +65,25 @@ def get_departement_region(code_postal): def get_data_schema() -> dict: # Récupération du schéma des données tabulaires - path = os.getenv("DATA_SCHEMA_PATH") - if path.startswith("http"): - original_schema: dict = get( - os.getenv("DATA_SCHEMA_PATH"), follow_redirects=True - ).json() - elif os.path.exists(path): - with open(path) as f: + url = os.getenv("DATA_SCHEMA_PATH") + local_path = Path(os.getenv("DATA_SCHEMA_LOCAL", "")) + + original_schema = {} + if url: + try: + original_schema: dict = get(url, follow_redirects=True).json() + except ( + httpx.ReadTimeout, + httpx.ReadError, + httpx.ConnectError, + httpx.ConnectTimeout, + ): + logger.error(f"Erreur HTTP lors de la récupération du schéma ({url})") + + if os.path.exists(local_path) and original_schema == {}: + with open(local_path) as f: original_schema: dict = json.load(f) - else: - raise Exception(f"Chemin vers le schéma invalide: {path}") + logger.info(f"Utilisation du schéma local ({local_path})") new_schema = OrderedDict() diff --git a/uv.lock b/uv.lock index 9ac4ecc..0742454 100644 --- a/uv.lock +++ b/uv.lock @@ -760,7 +760,7 @@ wheels = [ [[package]] name = "decp-info" -version = "2.7.6" +version = "2.7.7" source = { virtual = "." } dependencies = [ { name = "dash", extra = ["compress"] }, From 2e9b58761315675da6f63d37412f1d94746eff19 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 4 Jun 2026 21:13:35 +0200 Subject: [PATCH 070/151] =?UTF-8?q?Spec:=20page=20/etapes=20=E2=80=94=20do?= =?UTF-8?q?nn=C3=A9es=20par=20=C3=A9tape=20et=20par=20seuil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../2026-06-04-page-etapes-seuils-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-04-page-etapes-seuils-design.md diff --git a/docs/superpowers/specs/2026-06-04-page-etapes-seuils-design.md b/docs/superpowers/specs/2026-06-04-page-etapes-seuils-design.md new file mode 100644 index 0000000..8dd9f3f --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-page-etapes-seuils-design.md @@ -0,0 +1,114 @@ +# Page `/etapes` — « Quelles données pour quelles étapes et quels seuils ? » + +Date : 2026-06-04 +Branche : `dev` + +## Objectif + +Créer une page pédagogique sur decp.info qui montre, sur un seul graphique, **quelles données sont publiées à chaque étape de la passation d'un marché public** et **à partir de quel seuil réglementaire** (en € HT). + +La page aide à comprendre l'écosystème des publications de données de la commande publique et à situer les DECP (le cœur de decp.info) parmi les autres sources. + +## Portée + +- Une page dédiée à l'URL `/etapes`. +- Layout standard (bandeau de navigation global affiché en haut, comme toutes les pages). +- **Non listée** dans la navbar pour l'instant (on ne sait pas encore comment la lier depuis le reste de l'app — elle n'est pas secrète). +- **Référencée** dans le sitemap pour le SEO. +- Graphique en **HTML/CSS statique** (pas de Plotly, pas de SVG, pas d'interactivité). +- Pas de test automatisé spécifique (contenu statique) ; vérification visuelle via `python run.py`. + +Hors portée : tout lien entrant depuis la navbar ou d'autres pages, toute interactivité (survol, filtre), toute donnée dynamique. + +## Le graphique + +### Axes + +- **Axe Y** (de haut en bas) — étapes de la passation : + 1. Programmation + 2. Publicité (appel d'offres) + 3. Attribution + 4. Contrat — _vide_ (« aucune donnée publiée aujourd'hui ») + 5. Paiement — _vide_ (« aucune donnée publiée aujourd'hui ») +- **Axe X** — seuils réglementaires en € HT, **segmenté** (espacement égal entre seuils, pas linéaire, sinon tout serait écrasé entre 40 k€ et 5,4 M€). Marqueurs de colonnes : + - `0 €` + - `40 000 €` — seuil DECP + - `90 000 €` — seuil de publicité + - `140 000 € / 216 000 €` — seuils formalisés (UE) + - `5 404 000 €` — travaux (UE) + +### Barres (publications de données) + +Chaque barre est une bande horizontale colorée, positionnée sur sa ligne d'étape et couvrant la plage de seuils où la publication s'applique. + +| Publication | Étape(s) | Plage de seuils | Note | +| ------------------------------- | ------------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------- | +| **Approch** | Programmation | toute la largeur | sourcing / préinformation, publication **non réglementaire** | +| **Journaux d'annonces légales** | Publicité | 90 000 € → seuil formalisé | remplit exactement cette case | +| **BOAMP** | Publicité | ≥ 90 000 € (jusqu'à l'infini) | au-delà des seuils UE, publicité obligatoire au BOAMP **et** au JOUE | +| **JOUE** | Publicité (avis de marché) + Attribution (avis d'attribution) | ≥ seuils formalisés | deux barres, une par étape | +| **DECP** | Attribution | ≥ 40 000 € (jusqu'à l'infini) | données essentielles de la commande publique | + +### Légende + +Sous le graphique : une pastille de couleur + le nom complet pour chaque publication (Approch, Journaux d'annonces légales, BOAMP, JOUE, DECP). + +## Implémentation + +### Nouveau fichier `src/pages/etapes.py` + +Enregistrement de la page : + +```python +register_page( + __name__, + path="/etapes", + title="Quelles données pour quelles étapes et quels seuils ? | decp.info", + name="Étapes et données", + description="À chaque étape d'un marché public (programmation, publicité, attribution), quelles données sont publiées et à partir de quel seuil : DECP, BOAMP, JOUE, journaux d'annonces légales, Approch.", + image_url=META_CONTENT["image_url"], +) +``` + +Le `name="Étapes et données"` n'est pas dans la liste blanche de la navbar (`src/app.py:181`), la page reste donc hors navigation tout en étant accessible. + +`layout` = `html.Div(className="container", children=[...])` : + +1. `html.H2("Quelles données pour quelles étapes et quels seuils ?")` +2. Paragraphe d'intro (`dcc.Markdown`) expliquant ce que montre le graphique. +3. Le graphique (composants `html.Div` reproduisant la maquette v3, barres positionnées en `left`/`right` en `%`). +4. La légende. +5. Note de bas (`dcc.Markdown`) : axe X segmenté (non linéaire) ; Contrat et Paiement sans données ouvertes à ce jour. + +### Modification de `src/app.py` + +Ajouter `"/etapes"` à la liste des URLs du sitemap (`sitemap()`, ~ligne 73) : + +```python +pages = [ + "/", + "/observatoire", + "/tableau", + "/a-propos", + "/etapes", +] +``` + +Aucune modification de la navbar. + +### CSS + +Bloc dédié dans `src/assets/css/` (fichier existant ou nouveau), avec classes préfixées (ex. `.etapes-chart`, `.etapes-lane`, `.etapes-bar`…) pour éviter toute collision. + +Responsive : envelopper le graphique dans un conteneur `overflow-x:auto` avec une largeur minimale, afin que les barres restent lisibles sur petit écran plutôt que de s'écraser. + +## Vérification + +- `python run.py` puis ouvrir `/etapes` : le graphique s'affiche, fidèle à la maquette v3, avec le bandeau de navigation en haut. +- `/etapes` **absente** de la navbar. +- `/sitemap.xml` **contient** `/etapes`. +- Sur fenêtre étroite : défilement horizontal du graphique, pas d'écrasement. + +## Référence + +Maquette validée : `.superpowers/brainstorm/80498-1780599135/content/chart-concept-v3.html`. From 80c27bf3577066271346f5e354e2af28004bad6d Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 4 Jun 2026 21:26:09 +0200 Subject: [PATCH 071/151] =?UTF-8?q?Plan:=20page=20/etapes=20=E2=80=94=20do?= =?UTF-8?q?nn=C3=A9es=20par=20=C3=A9tape=20et=20par=20seuil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-04-page-etapes-seuils.md | 515 ++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-04-page-etapes-seuils.md diff --git a/docs/superpowers/plans/2026-06-04-page-etapes-seuils.md b/docs/superpowers/plans/2026-06-04-page-etapes-seuils.md new file mode 100644 index 0000000..c31edcb --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-page-etapes-seuils.md @@ -0,0 +1,515 @@ +# Page `/etapes` — « Quelles données pour quelles étapes et quels seuils ? » — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Créer une page statique `/etapes` qui affiche un graphique HTML/CSS montrant quelles données (Approch, Journaux d'annonces légales, BOAMP, JOUE, DECP) sont publiées à chaque étape de la passation d'un marché public et à partir de quel seuil réglementaire. + +**Architecture:** Une nouvelle page Dash auto-enregistrée (`src/pages/etapes.py`) qui expose un `layout` composé uniquement de `html.Div`/`dcc.Markdown` (aucun callback, aucune donnée dynamique). Le graphique est une grille CSS (1 colonne de libellés + 5 colonnes de seuils) où chaque publication est une barre positionnée en pourcentage. Le style vit dans `src/assets/css/style.css` (auto-chargé par Dash). L'URL est ajoutée au sitemap mais pas à la navbar. + +**Tech Stack:** Python 3, Dash 3.4 (pages API), CSS (grille + positionnement absolu), Flask (route sitemap existante). + +--- + +## Contexte pour l'engineer (à lire avant de commencer) + +- decp.info est une app Dash multi-pages. Chaque page est un module dans `src/pages/` qui appelle `register_page(...)` au niveau du module et expose une variable `layout`. Dash découvre ces pages automatiquement grâce à `use_pages=True` (voir `src/app.py:30`). +- **Imports** : toujours importer les modules de l'app avec le préfixe `src.` (ex. `from src.utils.seo import META_CONTENT`). +- La navbar (`src/app.py:170-182`) est construite à partir d'une **liste blanche de noms** : `["Recherche", "À propos", "Tableau", "Observatoire"]`. Une page dont le `name` n'est pas dans cette liste **n'apparaît pas** dans la navbar. On ne touche donc PAS à la navbar. +- Le sitemap (`src/app.py:70-86`) est une **liste d'URLs codée en dur**. Il faut y ajouter `/etapes`. +- Le CSS personnalisé est dans `src/assets/css/style.css` (Dash charge automatiquement tout ce qui est dans `src/assets/`). On y ajoute les règles du graphique. +- **Pré-requis commit** : ce dépôt utilise `pre-commit` (prettier, ruff). Les hooks ne tournent que si le virtualenv est activé. Avant chaque `git commit`, faire `source .venv/bin/activate` dans la même commande shell. Prettier peut reformater les fichiers Markdown/CSS : si un commit échoue parce que des fichiers ont été modifiés par un hook, refaire `git add` puis `git commit`. +- **Référence visuelle** : la maquette validée est `.superpowers/brainstorm/80498-1780599135/content/chart-concept-v3.html`. Le code HTML/CSS ci-dessous en est la transposition. +- Ce projet n'a **pas** de test automatisé pour cette page (contenu 100 % statique). La vérification est manuelle via `python run.py`. Les tâches ci-dessous remplacent donc le cycle TDD par des vérifications de rendu explicites. + +--- + +## File Structure + +- **Create** `src/pages/etapes.py` — la page : `register_page(...)` + `layout`. Contient une petite fonction interne `build_chart()` qui retourne le `html.Div` du graphique, pour garder le `layout` lisible. Responsabilité unique : décrire la page `/etapes`. +- **Modify** `src/app.py` — ajouter `"/etapes"` à la liste `pages` de la fonction `sitemap()`. +- **Modify** `src/assets/css/style.css` — ajouter un bloc de règles préfixées `.etapes-*` (graphique + responsive). + +--- + +## Task 1 : Squelette de la page `/etapes` + +**Files:** + +- Create: `src/pages/etapes.py` + +- [ ] **Step 1: Créer le fichier avec l'enregistrement de page et un layout minimal** + +Créer `src/pages/etapes.py` avec exactement ce contenu (le graphique sera ajouté en Task 2) : + +```python +from dash import dcc, html, register_page + +from src.utils.seo import META_CONTENT + +NAME = "Quelles données pour quelles étapes et quels seuils ?" + +register_page( + __name__, + path="/etapes", + title=f"{NAME} | decp.info", + name="Étapes et données", + description=( + "À chaque étape d'un marché public (programmation, publicité, " + "attribution), quelles données sont publiées et à partir de quel " + "seuil : DECP, BOAMP, JOUE, journaux d'annonces légales, Approch." + ), + image_url=META_CONTENT["image_url"], +) + +layout = html.Div( + className="container", + children=[ + html.H2(NAME), + dcc.Markdown( + "Un marché public passe par plusieurs étapes. À chacune, des " + "données peuvent être publiées — selon le montant du marché et " + "des obligations réglementaires. Ce graphique situe les " + "principales publications de données par **étape** (de haut en " + "bas) et par **seuil** (de gauche à droite, en euros hors taxes)." + ), + # Le graphique sera inséré ici en Task 2 + dcc.Markdown( + "**À noter :** l'axe horizontal n'est pas linéaire — les seuils " + "sont espacés régulièrement pour rester lisibles. Les étapes " + "*Contrat* et *Paiement* n'ont aujourd'hui aucune donnée publiée " + "en open data.", + className="etapes-note", + ), + ], +) +``` + +- [ ] **Step 2: Lancer l'app et vérifier que la page se charge** + +Run : + +```bash +source .venv/bin/activate && python run.py +``` + +Puis ouvrir `http://127.0.0.1:8050/etapes` dans un navigateur. +Expected : la page affiche le titre « Quelles données pour quelles étapes et quels seuils ? », le paragraphe d'intro et la note, avec le bandeau de navigation en haut. Aucune erreur dans la console du serveur. Arrêter le serveur (Ctrl-C). + +- [ ] **Step 3: Vérifier l'absence dans la navbar** + +Sur n'importe quelle page, vérifier visuellement que « Étapes et données » **n'apparaît pas** dans la barre de navigation (la liste blanche `src/app.py:181` ne la contient pas). +Expected : la navbar montre uniquement Recherche / Tableau / Observatoire / À propos. + +- [ ] **Step 4: Commit** + +```bash +source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat(etapes): squelette de la page /etapes" +``` + +(Si le commit échoue car un hook a reformaté le fichier : refaire `git add src/pages/etapes.py && git commit -m "feat(etapes): squelette de la page /etapes"`.) + +--- + +## Task 2 : Le graphique HTML/CSS + +**Files:** + +- Modify: `src/pages/etapes.py` + +Le graphique est une grille de 6 colonnes : 1 colonne de libellés d'étape (150 px) + 5 colonnes de seuils égales. L'en-tête X et chaque ligne d'étape occupent les colonnes 2 → 6 (`grid-column: 2 / -1`). À l'intérieur d'une ligne, les barres sont positionnées en `position:absolute` avec `left`/`right` en pourcentage, où chaque segment de seuil = 20 % de la largeur : + +- Segment 1 (0 € → 40 k€) : 0 % – 20 % +- Segment 2 (40 k€ → 90 k€) : 20 % – 40 % +- Segment 3 (90 k€ → 140/216 k€) : 40 % – 60 % +- Segment 4 (140/216 k€ → 5,404 M€) : 60 % – 80 % +- Segment 5 (≥ 5,404 M€) : 80 % – 100 % + +Une barre qui « commence à 40 k€ et va jusqu'à l'infini » s'écrit donc `left:20%; right:2%` (les `2%` de marge évitent de coller au bord). Une barre qui remplit la case 90 k€ → seuil formalisé s'écrit `left:40%; right:40%`. + +- [ ] **Step 1: Ajouter la fonction `build_chart()` au-dessus de `layout`** + +Dans `src/pages/etapes.py`, insérer cette fonction entre le bloc `register_page(...)` et la définition de `layout` : + +```python +def _lane(*bars): + """Une ligne d'étape : fond segmenté en 5 + barres positionnées.""" + return html.Div( + className="etapes-lane", + children=[ + html.Div( + className="etapes-segs", + children=[html.Div() for _ in range(5)], + ), + *bars, + ], + ) + + +def _bar(label, color, style): + base = {"backgroundColor": color} + base.update(style) + return html.Div(label, className="etapes-bar", style=base) + + +def build_chart(): + return html.Div( + className="etapes-chart-scroll", + children=html.Div( + className="etapes-chart", + children=[ + # En-tête : coin vide + 5 marqueurs de seuils + html.Div(className="etapes-corner"), + html.Div( + className="etapes-xhead", + children=[ + html.Div("0 €", className="etapes-xcell"), + html.Div( + [html.Strong("40 000 €"), "seuil DECP"], + className="etapes-xcell", + ), + html.Div( + [html.Strong("90 000 €"), "publicité"], + className="etapes-xcell", + ), + html.Div( + [html.Strong("140 k€ / 216 k€"), "seuils formalisés (UE)"], + className="etapes-xcell", + ), + html.Div( + [html.Strong("5,404 M€"), "travaux (UE)"], + className="etapes-xcell", + ), + ], + ), + # Programmation + html.Div("Programmation", className="etapes-stage"), + _lane( + _bar( + "Approch — sourcing / préinformation (non réglementaire)", + "#7c5cff", + {"left": "2%", "right": "2%"}, + ), + ), + # Publicité (appel d'offres) + html.Div( + ["Publicité ", html.Small("(appel d'offres)")], + className="etapes-stage", + ), + _lane( + _bar( + "Journaux d'annonces légales", + "#f79009", + {"left": "40%", "right": "40%", "top": "6px", "height": "20px"}, + ), + _bar( + "BOAMP", + "#1570ef", + {"left": "40%", "right": "2%", "top": "28px", "height": "20px"}, + ), + _bar( + "JOUE — avis de marché", + "#0e9384", + {"left": "60%", "right": "2%", "top": "6px", "height": "20px"}, + ), + ), + # Attribution + html.Div("Attribution", className="etapes-stage"), + _lane( + _bar( + "DECP — données essentielles", + "#12b76a", + {"left": "20%", "right": "2%", "top": "6px", "height": "20px"}, + ), + _bar( + "JOUE — avis d'attribution", + "#0e9384", + {"left": "60%", "right": "2%", "top": "28px", "height": "20px"}, + ), + ), + # Contrat (vide) + html.Div("Contrat", className="etapes-stage"), + html.Div( + "— aucune donnée publiée aujourd'hui —", + className="etapes-lane etapes-empty", + ), + # Paiement (vide) + html.Div("Paiement", className="etapes-stage"), + html.Div( + "— aucune donnée publiée aujourd'hui —", + className="etapes-lane etapes-empty", + ), + ], + ), + ) + + +def build_legend(): + items = [ + ("Approch", "#7c5cff"), + ("Journaux d'annonces légales", "#f79009"), + ("BOAMP", "#1570ef"), + ("JOUE", "#0e9384"), + ("DECP", "#12b76a"), + ] + return html.Div( + className="etapes-legend", + children=[ + html.Span( + [ + html.I(style={"backgroundColor": color}), + label, + ] + ) + for label, color in items + ], + ) +``` + +- [ ] **Step 2: Insérer le graphique et la légende dans `layout`** + +Dans `layout`, remplacer la ligne de commentaire `# Le graphique sera inséré ici en Task 2` par : + +```python + build_chart(), + build_legend(), +``` + +- [ ] **Step 3: Lancer l'app et vérifier le rendu** + +Run : + +```bash +source .venv/bin/activate && python run.py +``` + +Ouvrir `http://127.0.0.1:8050/etapes`. +Expected (comparer à la maquette `.superpowers/brainstorm/80498-1780599135/content/chart-concept-v3.html`) : + +- En-tête X : `0 € · 40 000 € (seuil DECP) · 90 000 € (publicité) · 140 k€/216 k€ (seuils formalisés UE) · 5,404 M€ (travaux UE)`. +- Lignes de haut en bas : Programmation (barre Approch pleine largeur), Publicité (Journaux d'annonces légales + BOAMP + JOUE), Attribution (DECP + JOUE), Contrat (vide), Paiement (vide). +- La barre « Journaux d'annonces légales » occupe la case 90 k€ → seuil formalisé ; DECP démarre à 40 k€ ; JOUE et BOAMP démarrent aux bons segments. +- La légende sous le graphique liste les 5 publications avec leurs couleurs. + +À ce stade le style brut (couleurs des barres) doit déjà être visible car appliqué inline ; la mise en page de la grille sera finalisée en Task 3. Si la grille n'est pas encore correcte (colonnes non alignées), c'est attendu — continuer en Task 3. Arrêter le serveur. + +- [ ] **Step 4: Commit** + +```bash +source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat(etapes): graphique données par étape et par seuil" +``` + +(Si échec dû à un hook : refaire `git add` puis `git commit`.) + +--- + +## Task 3 : CSS du graphique (grille + responsive) + +**Files:** + +- Modify: `src/assets/css/style.css` + +- [ ] **Step 1: Ajouter le bloc CSS à la fin de `src/assets/css/style.css`** + +Ajouter à la fin du fichier : + +```css +/* ===== Page /etapes : graphique données par étape et par seuil ===== */ + +.etapes-chart-scroll { + overflow-x: auto; + margin: 1rem 0; +} + +.etapes-chart { + min-width: 720px; + background: #fff; + border: 1px solid #d0d5dd; + border-radius: 8px; + overflow: hidden; + font-size: 13px; + display: grid; + grid-template-columns: 150px repeat(5, 1fr); +} + +.etapes-corner { + border-bottom: 2px solid #344054; +} + +.etapes-xhead { + grid-column: 2 / -1; + display: grid; + grid-template-columns: repeat(5, 1fr); + border-bottom: 2px solid #344054; +} + +.etapes-xcell { + text-align: center; + padding: 6px 2px; + font-size: 11px; + color: #475467; + border-left: 1px dashed #d0d5dd; +} + +.etapes-xcell strong { + display: block; + color: #101828; + font-size: 12px; +} + +.etapes-stage { + padding: 14px 10px; + font-weight: 600; + color: #101828; + border-bottom: 1px solid #eaecf0; + display: flex; + align-items: center; +} + +.etapes-stage small { + font-weight: 400; + color: #667085; +} + +.etapes-lane { + grid-column: 2 / -1; + position: relative; + border-bottom: 1px solid #eaecf0; + min-height: 52px; +} + +.etapes-segs { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(5, 1fr); +} + +.etapes-segs > div { + border-left: 1px dashed #eaecf0; +} + +.etapes-bar { + position: absolute; + top: 9px; + height: 32px; + border-radius: 6px; + color: #fff; + font-size: 11px; + font-weight: 600; + display: flex; + align-items: center; + padding: 0 10px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); + white-space: nowrap; + overflow: hidden; +} + +.etapes-empty { + color: #98a2b3; + font-style: italic; + padding: 14px; + display: flex; + align-items: center; +} + +.etapes-legend { + margin-top: 14px; + display: flex; + gap: 16px; + flex-wrap: wrap; + font-size: 12px; +} + +.etapes-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.etapes-legend i { + width: 14px; + height: 14px; + border-radius: 3px; + display: inline-block; +} + +.etapes-note { + margin-top: 8px; + color: #667085; + font-size: 13px; +} +``` + +- [ ] **Step 2: Lancer l'app et vérifier le rendu final** + +Run : + +```bash +source .venv/bin/activate && python run.py +``` + +Ouvrir `http://127.0.0.1:8050/etapes`. +Expected : le graphique est désormais identique à la maquette v3 — colonnes alignées, en-tête X avec ligne de séparation foncée, barres colorées bien positionnées dans chaque segment, lignes Contrat/Paiement grisées en italique, légende sous le graphique. + +- [ ] **Step 3: Vérifier le responsive (petit écran)** + +Dans le navigateur, réduire la fenêtre à ~500 px de large (ou ouvrir les devtools en mode mobile). +Expected : le graphique devient **défilable horizontalement** (grâce à `.etapes-chart-scroll { overflow-x:auto }` + `min-width:720px`), les barres ne s'écrasent pas. Arrêter le serveur. + +- [ ] **Step 4: Commit** + +```bash +source .venv/bin/activate && git add src/assets/css/style.css && git commit -m "feat(etapes): styles du graphique étapes/seuils" +``` + +(Si échec dû à un hook prettier : refaire `git add` puis `git commit`.) + +--- + +## Task 4 : Référencement de la page dans le sitemap + +**Files:** + +- Modify: `src/app.py` (fonction `sitemap()`, ~ligne 73) + +- [ ] **Step 1: Ajouter `/etapes` à la liste des URLs du sitemap** + +Dans `src/app.py`, dans la fonction `sitemap()`, modifier la liste `pages` : + +```python + pages = [ + "/", + "/observatoire", + "/tableau", + "/a-propos", + "/etapes", + ] +``` + +- [ ] **Step 2: Vérifier le sitemap** + +Run : + +```bash +source .venv/bin/activate && python run.py +``` + +Ouvrir `http://127.0.0.1:8050/sitemap.xml`. +Expected : le XML contient désormais une entrée `https://decp.info/etapes`. Arrêter le serveur. + +- [ ] **Step 3: Commit** + +```bash +source .venv/bin/activate && git add src/app.py && git commit -m "feat(etapes): référencement de /etapes dans le sitemap" +``` + +--- + +## Vérification finale (checklist de la spec) + +- [ ] `/etapes` affiche le graphique fidèle à la maquette v3, avec le bandeau de navigation global en haut. +- [ ] La page est **absente** de la navbar. +- [ ] `/sitemap.xml` **contient** `/etapes`. +- [ ] Sur fenêtre étroite, le graphique défile horizontalement sans s'écraser. +- [ ] Titre H2 de la page = « Quelles données pour quelles étapes et quels seuils ? ». +- [ ] `name` de la page = « Étapes et données ». From 6cf213add5698a9335ba2101a39b03346c890691 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 4 Jun 2026 21:31:17 +0200 Subject: [PATCH 072/151] =?UTF-8?q?Spec+plan:=20vue=20mobile=20d=C3=A9di?= =?UTF-8?q?=C3=A9e=20pour=20/etapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-04-page-etapes-seuils.md | 208 +++++++++++++++++- .../2026-06-04-page-etapes-seuils-design.md | 12 +- 2 files changed, 206 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-page-etapes-seuils.md b/docs/superpowers/plans/2026-06-04-page-etapes-seuils.md index c31edcb..66978ba 100644 --- a/docs/superpowers/plans/2026-06-04-page-etapes-seuils.md +++ b/docs/superpowers/plans/2026-06-04-page-etapes-seuils.md @@ -4,7 +4,7 @@ **Goal:** Créer une page statique `/etapes` qui affiche un graphique HTML/CSS montrant quelles données (Approch, Journaux d'annonces légales, BOAMP, JOUE, DECP) sont publiées à chaque étape de la passation d'un marché public et à partir de quel seuil réglementaire. -**Architecture:** Une nouvelle page Dash auto-enregistrée (`src/pages/etapes.py`) qui expose un `layout` composé uniquement de `html.Div`/`dcc.Markdown` (aucun callback, aucune donnée dynamique). Le graphique est une grille CSS (1 colonne de libellés + 5 colonnes de seuils) où chaque publication est une barre positionnée en pourcentage. Le style vit dans `src/assets/css/style.css` (auto-chargé par Dash). L'URL est ajoutée au sitemap mais pas à la navbar. +**Architecture:** Une nouvelle page Dash auto-enregistrée (`src/pages/etapes.py`) qui expose un `layout` composé uniquement de `html.Div`/`dcc.Markdown` (aucun callback, aucune donnée dynamique). La page rend **deux représentations des mêmes données** basculées par media query : sur desktop/tablette, un graphique en grille CSS (1 colonne de libellés + 5 colonnes de seuils) où chaque publication est une barre positionnée en pourcentage ; sur mobile portrait (< 768 px), une liste verticale par étape. Le style vit dans `src/assets/css/style.css` (auto-chargé par Dash). L'URL est ajoutée au sitemap mais pas à la navbar. **Tech Stack:** Python 3, Dash 3.4 (pages API), CSS (grille + positionnement absolu), Flask (route sitemap existante). @@ -25,9 +25,9 @@ ## File Structure -- **Create** `src/pages/etapes.py` — la page : `register_page(...)` + `layout`. Contient une petite fonction interne `build_chart()` qui retourne le `html.Div` du graphique, pour garder le `layout` lisible. Responsabilité unique : décrire la page `/etapes`. +- **Create** `src/pages/etapes.py` — la page : `register_page(...)` + `layout`. Contient `build_chart()` (graphique grille desktop), `build_mobile()` (liste verticale mobile, alimentée par la structure `STAGES_MOBILE`) et `build_legend()`, pour garder le `layout` lisible. Responsabilité unique : décrire la page `/etapes`. - **Modify** `src/app.py` — ajouter `"/etapes"` à la liste `pages` de la fonction `sitemap()`. -- **Modify** `src/assets/css/style.css` — ajouter un bloc de règles préfixées `.etapes-*` (graphique + responsive). +- **Modify** `src/assets/css/style.css` — ajouter un bloc de règles préfixées `.etapes-*` : graphique en grille, liste mobile `.etapes-m-*`, et media query de bascule à 768 px. --- @@ -290,7 +290,7 @@ Expected (comparer à la maquette `.superpowers/brainstorm/80498-1780599135/cont - La barre « Journaux d'annonces légales » occupe la case 90 k€ → seuil formalisé ; DECP démarre à 40 k€ ; JOUE et BOAMP démarrent aux bons segments. - La légende sous le graphique liste les 5 publications avec leurs couleurs. -À ce stade le style brut (couleurs des barres) doit déjà être visible car appliqué inline ; la mise en page de la grille sera finalisée en Task 3. Si la grille n'est pas encore correcte (colonnes non alignées), c'est attendu — continuer en Task 3. Arrêter le serveur. +À ce stade le style brut (couleurs des barres) doit déjà être visible car appliqué inline ; la mise en page de la grille sera finalisée en Task 4. Si la grille n'est pas encore correcte (colonnes non alignées), c'est attendu — continuer. Arrêter le serveur. - [ ] **Step 4: Commit** @@ -302,7 +302,114 @@ source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat( --- -## Task 3 : CSS du graphique (grille + responsive) +## Task 3 : Vue mobile (liste verticale par étape) + +**Files:** + +- Modify: `src/pages/etapes.py` + +Sur écran portrait étroit, le graphique en grille n'est pas lisible (vue d'ensemble perdue). On ajoute une **liste verticale par étape** qui décrit les mêmes données en texte. Le basculement entre les deux rendus se fera en CSS (Task 4). Pour éviter la duplication, les publications de chaque étape sont décrites dans une structure de données Python consommée par le rendu mobile. + +- [ ] **Step 1: Ajouter la structure de données et `build_mobile()`** + +Dans `src/pages/etapes.py`, ajouter ce bloc juste avant la fonction `build_legend()` : + +```python +# Données par étape, partagées par la vue mobile. +# Chaque item : (libellé, couleur, plage de seuils en texte). +STAGES_MOBILE = [ + ( + "Programmation", + [ + ("Approch", "#7c5cff", "tous montants — publication non réglementaire"), + ], + ), + ( + "Publicité (appel d'offres)", + [ + ("Journaux d'annonces légales", "#f79009", "de 90 000 € au seuil formalisé"), + ("BOAMP", "#1570ef", "à partir de 90 000 €"), + ( + "JOUE — avis de marché", + "#0e9384", + "à partir des seuils formalisés (140 k€ / 216 k€)", + ), + ], + ), + ( + "Attribution", + [ + ("DECP — données essentielles", "#12b76a", "à partir de 40 000 €"), + ("JOUE — avis d'attribution", "#0e9384", "à partir des seuils formalisés"), + ], + ), + ("Contrat", []), + ("Paiement", []), +] + + +def build_mobile(): + blocks = [] + for stage, items in STAGES_MOBILE: + if items: + children = [ + html.Div( + [ + html.I(style={"backgroundColor": color}), + html.Span(label, className="etapes-m-label"), + html.Span(seuil, className="etapes-m-seuil"), + ], + className="etapes-m-item", + ) + for label, color, seuil in items + ] + else: + children = [ + html.Div( + "aucune donnée publiée aujourd'hui", + className="etapes-m-item etapes-m-empty", + ) + ] + blocks.append( + html.Div( + [html.H4(stage, className="etapes-m-stage"), *children], + className="etapes-m-block", + ) + ) + return html.Div(blocks, className="etapes-mobile") +``` + +- [ ] **Step 2: Insérer `build_mobile()` dans `layout`** + +Dans `layout`, la ligne `build_chart(),` (insérée en Task 2) est suivie de `build_mobile(),`, soit : + +```python + build_chart(), + build_mobile(), + build_legend(), +``` + +- [ ] **Step 3: Lancer l'app et vérifier (rendu brut, avant CSS de bascule)** + +Run : + +```bash +source .venv/bin/activate && python run.py +``` + +Ouvrir `http://127.0.0.1:8050/etapes`. À ce stade les deux rendus s'affichent l'un sous l'autre (la bascule CSS arrive en Task 4) : sous le graphique, la liste affiche Programmation (Approch…), Publicité (3 publications), Attribution (2 publications), puis Contrat et Paiement avec « aucune donnée publiée aujourd'hui ». C'est attendu. Arrêter le serveur. + +- [ ] **Step 4: Commit** + +```bash +source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat(etapes): vue mobile liste par étape" +``` + +(Si échec dû à un hook : refaire `git add` puis `git commit`.) + +--- + +## Task 4 : CSS du graphique + bascule mobile **Files:** @@ -438,6 +545,77 @@ Ajouter à la fin du fichier : color: #667085; font-size: 13px; } + +/* --- Vue mobile (liste par étape) : masquée par défaut --- */ + +.etapes-mobile { + display: none; + margin: 1rem 0; +} + +.etapes-m-block { + border: 1px solid #d0d5dd; + border-radius: 8px; + margin-bottom: 12px; + overflow: hidden; +} + +.etapes-m-stage { + margin: 0; + padding: 10px 12px; + background: #f9fafb; + border-bottom: 1px solid #eaecf0; + font-size: 15px; + color: #101828; +} + +.etapes-m-item { + display: flex; + align-items: baseline; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid #f2f4f7; + font-size: 13px; +} + +.etapes-m-item:last-child { + border-bottom: none; +} + +.etapes-m-item i { + width: 12px; + height: 12px; + border-radius: 3px; + flex: 0 0 auto; + position: relative; + top: 2px; +} + +.etapes-m-label { + font-weight: 600; + color: #101828; +} + +.etapes-m-seuil { + color: #667085; +} + +.etapes-m-empty { + color: #98a2b3; + font-style: italic; +} + +/* --- Bascule desktop / mobile au point de rupture 768 px --- */ + +@media (max-width: 768px) { + .etapes-chart-scroll, + .etapes-legend { + display: none; + } + .etapes-mobile { + display: block; + } +} ``` - [ ] **Step 2: Lancer l'app et vérifier le rendu final** @@ -448,13 +626,18 @@ Run : source .venv/bin/activate && python run.py ``` -Ouvrir `http://127.0.0.1:8050/etapes`. -Expected : le graphique est désormais identique à la maquette v3 — colonnes alignées, en-tête X avec ligne de séparation foncée, barres colorées bien positionnées dans chaque segment, lignes Contrat/Paiement grisées en italique, légende sous le graphique. +Ouvrir `http://127.0.0.1:8050/etapes` en grand écran (≥ 768 px). +Expected : le graphique est identique à la maquette v3 — colonnes alignées, en-tête X avec ligne de séparation foncée, barres colorées bien positionnées dans chaque segment, lignes Contrat/Paiement grisées en italique, légende sous le graphique. La **liste mobile est masquée** (le graphique seul est visible). -- [ ] **Step 3: Vérifier le responsive (petit écran)** +- [ ] **Step 3: Vérifier la bascule responsive** -Dans le navigateur, réduire la fenêtre à ~500 px de large (ou ouvrir les devtools en mode mobile). -Expected : le graphique devient **défilable horizontalement** (grâce à `.etapes-chart-scroll { overflow-x:auto }` + `min-width:720px`), les barres ne s'écrasent pas. Arrêter le serveur. +Dans le navigateur, ouvrir les devtools et passer en mode mobile portrait (largeur < 768 px, ex. iPhone SE 375 px). Tester aussi une largeur intermédiaire (~800 px). +Expected : + +- À largeur intermédiaire (~800 px, ≥ 768) : le **graphique** s'affiche, défilable horizontalement (`overflow-x:auto` + `min-width:720px`), barres non écrasées ; liste mobile masquée. +- En portrait (< 768 px) : le graphique **et la légende disparaissent**, remplacés par la **liste verticale par étape** — chaque étape est un bloc avec son titre, et chaque publication a sa pastille de couleur, son nom et sa plage de seuils en texte. Aucun défilement horizontal nécessaire. Contrat/Paiement affichent « aucune donnée publiée aujourd'hui » en italique. + +Arrêter le serveur. - [ ] **Step 4: Commit** @@ -466,7 +649,7 @@ source .venv/bin/activate && git add src/assets/css/style.css && git commit -m " --- -## Task 4 : Référencement de la page dans le sitemap +## Task 5 : Référencement de la page dans le sitemap **Files:** @@ -510,6 +693,7 @@ source .venv/bin/activate && git add src/app.py && git commit -m "feat(etapes): - [ ] `/etapes` affiche le graphique fidèle à la maquette v3, avec le bandeau de navigation global en haut. - [ ] La page est **absente** de la navbar. - [ ] `/sitemap.xml` **contient** `/etapes`. -- [ ] Sur fenêtre étroite, le graphique défile horizontalement sans s'écraser. +- [ ] Sur fenêtre intermédiaire (≥ 768 px), le graphique défile horizontalement sans s'écraser. +- [ ] Sur écran portrait étroit (< 768 px), le graphique est masqué et remplacé par la liste verticale par étape, lisible sans défilement horizontal. - [ ] Titre H2 de la page = « Quelles données pour quelles étapes et quels seuils ? ». - [ ] `name` de la page = « Étapes et données ». diff --git a/docs/superpowers/specs/2026-06-04-page-etapes-seuils-design.md b/docs/superpowers/specs/2026-06-04-page-etapes-seuils-design.md index 8dd9f3f..49753c9 100644 --- a/docs/superpowers/specs/2026-06-04-page-etapes-seuils-design.md +++ b/docs/superpowers/specs/2026-06-04-page-etapes-seuils-design.md @@ -100,14 +100,22 @@ Aucune modification de la navbar. Bloc dédié dans `src/assets/css/` (fichier existant ou nouveau), avec classes préfixées (ex. `.etapes-chart`, `.etapes-lane`, `.etapes-bar`…) pour éviter toute collision. -Responsive : envelopper le graphique dans un conteneur `overflow-x:auto` avec une largeur minimale, afin que les barres restent lisibles sur petit écran plutôt que de s'écraser. +### Responsive — deux rendus + +Le graphique en grille n'est pas lisible sur écran portrait étroit (la vue d'ensemble est perdue). On rend donc **deux représentations des mêmes données**, basculées par media query (point de rupture ~768 px) : + +- **Desktop / tablette (≥ 768 px)** : le graphique en grille (maquette v3), enveloppé dans un conteneur `overflow-x:auto` + `min-width` pour les écrans intermédiaires. Le rendu mobile est masqué. +- **Mobile (< 768 px)** : le graphique est masqué et remplacé par une **liste verticale par étape**. Chaque étape est un bloc qui liste ses publications, chacune avec sa pastille de couleur, son nom, et sa **plage de seuils en texte** (ex. « DECP — à partir de 40 000 € »). Les étapes Contrat/Paiement affichent « aucune donnée publiée aujourd'hui ». + +Pour éviter la duplication, les publications de chaque étape (libellé, couleur, texte de plage) sont décrites **une seule fois** dans une structure de données Python, consommée par le rendu mobile et la légende. Le graphique en grille garde son positionnement explicite (intrinsèquement spatial). ## Vérification - `python run.py` puis ouvrir `/etapes` : le graphique s'affiche, fidèle à la maquette v3, avec le bandeau de navigation en haut. - `/etapes` **absente** de la navbar. - `/sitemap.xml` **contient** `/etapes`. -- Sur fenêtre étroite : défilement horizontal du graphique, pas d'écrasement. +- Sur fenêtre intermédiaire : défilement horizontal du graphique, pas d'écrasement. +- Sur écran portrait étroit (< 768 px) : le graphique en grille est masqué, remplacé par la liste verticale par étape, lisible sans défilement horizontal. ## Référence From e55f3447db9ba85cb31ef8de35aef0d784b012a4 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 4 Jun 2026 21:34:36 +0200 Subject: [PATCH 073/151] =?UTF-8?q?Possibilit=C3=A9=20de=20charger=20les?= =?UTF-8?q?=20donn=C3=A9es=20depuis=20une=20URL,=20memoisation=20de=20last?= =?UTF-8?q?=5Fmodified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/app.py | 22 +- src/db.py | 27 +- src/pages/tableau.py | 4 +- src/utils/__init__.py | 24 + uv.lock | 2654 +++++++++++++++++++++-------------------- 6 files changed, 1388 insertions(+), 1345 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 362752d..f61635b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,6 @@ env = [ "DATA_FILE_PARQUET_PATH=tests/test.parquet", "DEVELOPMENT=true", "REBUILD_DUCKDB=true", - "DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json", + "DATA_SCHEMA_LOCAL=/home/colin/git/decp-processing/dist/schema.json", ] addopts = "-p no:warnings" diff --git a/src/app.py b/src/app.py index 40a40d5..ad75f04 100644 --- a/src/app.py +++ b/src/app.py @@ -6,7 +6,7 @@ import pandas # noqa: F401 # eager import: avoid plotly's lazy-import race acr import tomllib from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry from dotenv import load_dotenv -from flask import Response +from flask import Flask, Response from src.utils import DEVELOPMENT from src.utils.cache import cache @@ -27,12 +27,10 @@ META_TAGS = [ if DEVELOPMENT: META_TAGS.append({"name": "robots", "content": "noindex"}) -app: Dash = Dash( - title="decp.info", - use_pages=True, - compress=True, - meta_tags=META_TAGS, -) +# Le cache doit être initialisé AVANT la construction de Dash : `use_pages=True` +# importe les modules de pages pendant l'instanciation, et certains appellent des +# fonctions memoizées (@cache.memoize) dès l'import (ex. tableau.py). +server = Flask(__name__) cache_dir = os.getenv("CACHE_DIR", "/tmp/decp-cache") @@ -40,7 +38,7 @@ if os.path.exists(cache_dir): rmtree(cache_dir) cache.init_app( - app.server, + server, config={ "CACHE_TYPE": "FileSystemCache", "CACHE_DIR": cache_dir, @@ -51,6 +49,14 @@ cache.init_app( }, ) +app: Dash = Dash( + server=server, + title="decp.info", + use_pages=True, + compress=True, + meta_tags=META_TAGS, +) + # robots.txt @app.server.route("/robots.txt") diff --git a/src/db.py b/src/db.py index a0e1978..7fca3de 100644 --- a/src/db.py +++ b/src/db.py @@ -8,27 +8,31 @@ import polars as pl import polars.selectors as cs from polars.exceptions import ComputeError -from src.utils import logger +from src.utils import get_last_modified, logger -def should_rebuild(db_path: Path, parquet_path: Path) -> bool: +def should_rebuild(db_path: Path, parquet_path: str) -> bool: db_path = Path(db_path) - parquet_path = Path(parquet_path) if not db_path.exists(): return True dev = os.getenv("DEVELOPMENT", "False").lower() == "true" force = os.getenv("REBUILD_DUCKDB", "False").lower() == "true" if dev and not force: return False - return parquet_path.stat().st_mtime > db_path.stat().st_mtime + last_modified: float = get_last_modified(parquet_path) + return last_modified > db_path.stat().st_mtime -def _load_source_frame(parquet_path: Path) -> pl.DataFrame: +def _load_source_frame() -> pl.DataFrame: """Read the source parquet and apply the row-level transforms. Kept here (not in utils.py) so src.db has no dependency on utils. Mirrors the behavior previously in utils.get_decp_data(). """ + + parquet_path: str = os.getenv("DATA_FILE_PARQUET_PATH", "") + if not (parquet_path.startswith("http")): + assert os.path.exists(parquet_path) try: lff: pl.LazyFrame = pl.scan_parquet(str(parquet_path)) except ComputeError: @@ -58,20 +62,21 @@ def _load_source_frame(parquet_path: Path) -> pl.DataFrame: return lff.collect() -def build_database(db_path: Path, parquet_path: Path) -> None: +def build_database(db_path: Path) -> None: """Build the DuckDB database atomically under an exclusive lock. Caller MUST hold the fcntl.flock on the .lock file. """ db_path = Path(db_path) - parquet_path = Path(parquet_path) tmp_path = db_path.with_suffix(".duckdb.tmp") staging_parquet = db_path.with_suffix(".staging.parquet") if tmp_path.exists(): tmp_path.unlink() - logger.info(f"Construction de la base DuckDB à partir de {parquet_path}...") - frame = _load_source_frame(parquet_path) + logger.info( + f"Construction de la base DuckDB à partir de {os.getenv('DATA_FILE_PARQUET_PATH', '')}..." + ) + frame = _load_source_frame() # Write transformed frame as parquet so DuckDB can read it natively # (avoids pyarrow dependency for the Polars→DuckDB handoff) @@ -111,13 +116,13 @@ def build_database(db_path: Path, parquet_path: Path) -> None: def _ensure_database() -> Path: db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb")) - parquet_path = Path(os.getenv("DATA_FILE_PARQUET_PATH")) + parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "") lock_path = db_path.with_suffix(".duckdb.lock") with open(lock_path, "w") as lock_fd: fcntl.flock(lock_fd, fcntl.LOCK_EX) if should_rebuild(db_path, parquet_path): - build_database(db_path, parquet_path) + build_database(db_path) else: logger.debug("Base de données déjà disponible et à jour.") return db_path diff --git a/src/pages/tableau.py b/src/pages/tableau.py index a113d4b..167b8ca 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -21,7 +21,7 @@ from dash import ( from src.db import query_marches, schema from src.figures import DataTable, make_column_picker -from src.utils import logger +from src.utils import get_last_modified, logger from src.utils.seo import META_CONTENT from src.utils.table import ( COLUMNS, @@ -33,7 +33,7 @@ from src.utils.table import ( ) from src.utils.tracking import track_search -update_date_timestamp = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH")) +update_date_timestamp = get_last_modified(os.getenv("DATA_FILE_PARQUET_PATH", "")) update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y") update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat() diff --git a/src/utils/__init__.py b/src/utils/__init__.py index ecbd52b..797209d 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -1,5 +1,29 @@ import logging import os +from datetime import datetime +from pathlib import Path + +import httpx + +from src.utils.cache import cache + + +@cache.memoize() +def get_last_modified(parquet_path: str) -> float: + logger.info("Récupération de la date de modification des données...") + logging.getLogger("httpx").setLevel("WARNING") + if parquet_path.startswith("http"): + last_modified = httpx.head( + url=parquet_path, + follow_redirects=True, + ).headers["last-modified"] + last_modified = datetime.strptime(last_modified, "%a, %d %b %Y %X %Z").strftime( + "%s" + ) + return float(last_modified) + parquet_local_path = Path(parquet_path) + return parquet_local_path.stat().st_mtime + logging.basicConfig( format="%(asctime)s %(levelname)-8s %(message)s", diff --git a/uv.lock b/uv.lock index 0742454..77eb9b6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -18,9 +18,9 @@ resolution-markers = [ name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -32,123 +32,123 @@ dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "attrs" version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "backports-zstd" version = "1.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/36a5182ce1d8ef9ef32bff69037bd28b389bbdb66338f8069e61da7028cb/backports_zstd-1.3.0.tar.gz", hash = "sha256:e8b2d68e2812f5c9970cabc5e21da8b409b5ed04e79b4585dbffa33e9b45ebe2", size = 997138 } +sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/36a5182ce1d8ef9ef32bff69037bd28b389bbdb66338f8069e61da7028cb/backports_zstd-1.3.0.tar.gz", hash = "sha256:e8b2d68e2812f5c9970cabc5e21da8b409b5ed04e79b4585dbffa33e9b45ebe2", size = 997138, upload-time = "2025-12-29T17:28:06.143Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/70/766f6ebbb9db2ed75951f0a671ee15931dc69278c84d9f09b08dd6b67c3e/backports_zstd-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a2db17a6d9bf6b4dc223b3f6414aa9db6d1afe9de9bff61d582c2934ca456a0", size = 435664 }, - { url = "https://files.pythonhosted.org/packages/55/f8/7b3fad9c6ee5ff3bcd7c941586675007330197ff4a388f01c73198ecc8bb/backports_zstd-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7f16b98ba81780a9517ce6c493e1aea9b7d72de2b1efa08375136c270e1ecba", size = 362060 }, - { url = "https://files.pythonhosted.org/packages/68/9e/cad0f508ed7c3fbd07398f22b5bf25aa0523fcf56c84c3def642909e80ae/backports_zstd-1.3.0-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:1124a169a647671ccb4654a0ef1d0b42d6735c45ce3d0adf609df22fb1f099db", size = 505958 }, - { url = "https://files.pythonhosted.org/packages/b7/dc/96dc55c043b0d86e53ae9608b496196936244c1ecf7e95cdf66d0dbc0f23/backports_zstd-1.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8410fda08b36202d01ab4503f6787c763898888cb1a48c19fce94711563d3ee3", size = 475571 }, - { url = "https://files.pythonhosted.org/packages/20/48/d9c8c8c2a5ac57fc5697f1945254af31407b0c5f80335a175a7c215b4118/backports_zstd-1.3.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab139d1fc0e91a697e82fa834e6404098802f11b6035607174776173ded9a2cc", size = 581199 }, - { url = "https://files.pythonhosted.org/packages/0d/ca/7fe70d2d39ed39e26a6c6f6c1dd229f1ab889500d5c90b17527702b1a21e/backports_zstd-1.3.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f3115d203f387f77c23b5461fb6678d282d4f276f9f39298ad242b00120afc7", size = 640846 }, - { url = "https://files.pythonhosted.org/packages/0e/d8/5b8580469e70b72402212885bf19b9d31eaf23549b602e0c294edf380e25/backports_zstd-1.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:116f65cce84e215dfac0414924b051faf8d29dc7188cf3944dd1e5be8dd15a32", size = 491061 }, - { url = "https://files.pythonhosted.org/packages/cc/dd/17a752263fccd1ba24184b7e89c14cd31553d512e2e5b065f38e63a0ba86/backports_zstd-1.3.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04def169e4a9ae291298124da4e097c6d6545d0e93164f934b716da04d24630a", size = 565071 }, - { url = "https://files.pythonhosted.org/packages/1a/81/df23d3fe664b2497ab2ec01dc012cb9304e7d568c67f50b1b324fb2d8cbb/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:481b586291ef02a250f03d4c31a37c9881e5e93556568abbd20ca1ad720d443f", size = 481518 }, - { url = "https://files.pythonhosted.org/packages/ba/cd/e50dd85fde890c5d79e1ed5dc241f1c45f87b6c12571fdb60add57f2ee66/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0290979eea67f7275fa42d5859cc5bea94f2c08cca6bc36396673476773d2bad", size = 509464 }, - { url = "https://files.pythonhosted.org/packages/d3/bb/e429156e4b834837fe78b4f32ed512491aea39415444420c79ccd3aa0526/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:01c699d8c803dc9f9c9d6ede21b75ec99f45c3b411821011692befca538928cb", size = 585563 }, - { url = "https://files.pythonhosted.org/packages/95/c0/1a0d245325827242aefe76f4f3477ec183b996b8db5105698564f8303481/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2c662912cfc1a5ebd1d2162ac651549d58bd3c97a8096130ec13c703fca355f2", size = 562889 }, - { url = "https://files.pythonhosted.org/packages/93/42/126b2bc7540a15452c3ebdf190ebfea8a8644e29b22f4e10e2a6aa2389e4/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3180c8eb085396928e9946167e610aa625922b82c3e2263c5f17000556370168", size = 631423 }, - { url = "https://files.pythonhosted.org/packages/dc/32/018e49657411582569032b7d1bb5d62e514aad8b44952de740ec6250588d/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5b9a8c75a294e7ffa18fc8425a763facc366435a8b442e4dffdc19fa9499a22c", size = 495122 }, - { url = "https://files.pythonhosted.org/packages/c2/9e/cdd1d2e1d3612bb90d9cf9b23bea06f2155cdafccd8b6f28a1c4d7750004/backports_zstd-1.3.0-cp310-cp310-win32.whl", hash = "sha256:845defdb172385f17123d92a00d2e952d341e9ae310bfa2410c292bf03846034", size = 288573 }, - { url = "https://files.pythonhosted.org/packages/55/7c/2e9c80f08375bd14262cefa69297a926134f517c9955c0795eec5e1d470e/backports_zstd-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:43a9fea6299c801da85221e387b32d90a9ad7c62aa2a34edf525359ce5ad8f3a", size = 313506 }, - { url = "https://files.pythonhosted.org/packages/c5/5d/fa67e8174f54db44eb33498abb7f98bea4f2329e873b225391bda0113a5e/backports_zstd-1.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:df8473cb117e1316e6c6101f2724e025bd8f50af2dc009d0001c0aabfb5eb57c", size = 288688 }, - { url = "https://files.pythonhosted.org/packages/ac/28/ed31a0e35feb4538a996348362051b52912d50f00d25c2d388eccef9242c/backports_zstd-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:249f90b39d3741c48620021a968b35f268ca70e35f555abeea9ff95a451f35f9", size = 435660 }, - { url = "https://files.pythonhosted.org/packages/00/0d/3db362169d80442adda9dd563c4f0bb10091c8c1c9a158037f4ecd53988e/backports_zstd-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b0e71e83e46154a9d3ced6d4de9a2fea8207ee1e4832aeecf364dc125eda305c", size = 362056 }, - { url = "https://files.pythonhosted.org/packages/bd/00/b67ba053a7d6f6dbe2f8a704b7d3a5e01b1d2e2e8edbc9b634f2702ef73c/backports_zstd-1.3.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:cbc6193acd21f96760c94dd71bf32b161223e8503f5277acb0a5ab54e5598957", size = 505957 }, - { url = "https://files.pythonhosted.org/packages/6f/3e/2667c0ddb53ddf28667e330bf9fe92e8e17705a481c9b698e283120565f7/backports_zstd-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1df583adc0ae84a8d13d7139f42eade6d90182b1dd3e0d28f7df3c564b9fd55d", size = 475569 }, - { url = "https://files.pythonhosted.org/packages/eb/86/4052473217bd954ccdffda5f7264a0e99e7c4ecf70c0f729845c6a45fc5a/backports_zstd-1.3.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d833fc23aa3cc2e05aeffc7cfadd87b796654ad3a7fb214555cda3f1db2d4dc2", size = 581196 }, - { url = "https://files.pythonhosted.org/packages/e5/bd/064f6fdb61db3d2c473159ebc844243e650dc032de0f8208443a00127925/backports_zstd-1.3.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:142178fe981061f1d2a57c5348f2cd31a3b6397a35593e7a17dbda817b793a7f", size = 640888 }, - { url = "https://files.pythonhosted.org/packages/d8/09/0822403f40932a165a4f1df289d41653683019e4fd7a86b63ed20e9b6177/backports_zstd-1.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5eed0a09a163f3a8125a857cb031be87ed052e4a47bc75085ed7fca786e9bb5b", size = 491100 }, - { url = "https://files.pythonhosted.org/packages/a6/a3/f5ac28d74039b7e182a780809dc66b9dbfc893186f5d5444340bba135389/backports_zstd-1.3.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60aa483fef5843749e993dde01229e5eedebca8c283023d27d6bf6800d1d4ce3", size = 565071 }, - { url = "https://files.pythonhosted.org/packages/e1/ac/50209aeb92257a642ee987afa1e61d5b6731ab6bf0bff70905856e5aede6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ea0886c1b619773544546e243ed73f6d6c2b1ae3c00c904ccc9903a352d731e1", size = 481519 }, - { url = "https://files.pythonhosted.org/packages/08/1f/b06f64199fb4b2e9437cedbf96d0155ca08aeec35fe81d41065acd44762e/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5e137657c830a5ce99be40a1d713eb1d246bae488ada28ff0666ac4387aebdd5", size = 509465 }, - { url = "https://files.pythonhosted.org/packages/f4/37/2c365196e61c8fffbbc930ffd69f1ada7aa1c7210857b3e565031c787ac6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:94048c8089755e482e4b34608029cf1142523a625873c272be2b1c9253871a72", size = 585552 }, - { url = "https://files.pythonhosted.org/packages/93/8d/c2c4f448bb6b6c9df17410eaedce415e8db0eb25b60d09a3d22a98294d09/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d339c1ec40485e97e600eb9a285fb13169dbf44c5094b945788a62f38b96e533", size = 562893 }, - { url = "https://files.pythonhosted.org/packages/74/e8/2110d4d39115130f7514cbbcec673a885f4052bb68d15e41bc96a7558856/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8aeee9210c54cf8bf83f4d263a6d0d6e7a0298aeb5a14a0a95e90487c5c3157c", size = 631462 }, - { url = "https://files.pythonhosted.org/packages/b9/a8/d64b59ae0714fdace14e43873f794eff93613e35e3e85eead33a4f44cd80/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba7114a3099e5ea05cbb46568bd0e08bca2ca11e12c6a7b563a24b86b2b4a67f", size = 495125 }, - { url = "https://files.pythonhosted.org/packages/ef/d8/bcff0a091fcf27172c57ae463e49d8dec6dc31e01d7e7bf1ae3aad9c3566/backports_zstd-1.3.0-cp311-cp311-win32.whl", hash = "sha256:08dfdfb85da5915383bfae680b6ac10ab5769ab22e690f9a854320720011ae8e", size = 288664 }, - { url = "https://files.pythonhosted.org/packages/28/1a/379061e2abf8c3150ad51c1baab9ac723e01cf7538860a6a74c48f8b73ee/backports_zstd-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8aac2e7cdcc8f310c16f98a0062b48d0a081dbb82862794f4f4f5bdafde30a4", size = 313633 }, - { url = "https://files.pythonhosted.org/packages/35/e7/eca40858883029fc716660106069b23253e2ec5fd34e86b4101c8cfe864b/backports_zstd-1.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:440ef1be06e82dc0d69dbb57177f2ce98bbd2151013ee7e551e2f2b54caa6120", size = 288814 }, - { url = "https://files.pythonhosted.org/packages/72/d4/356da49d3053f4bc50e71a8535631b57bc9ca4e8c6d2442e073e0ab41c44/backports_zstd-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f4a292e357f3046d18766ce06d990ccbab97411708d3acb934e63529c2ea7786", size = 435972 }, - { url = "https://files.pythonhosted.org/packages/30/8f/dbe389e60c7e47af488520f31a4aa14028d66da5bf3c60d3044b571eb906/backports_zstd-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb4c386f38323698991b38edcc9c091d46d4713f5df02a3b5c80a28b40e289ea", size = 362124 }, - { url = "https://files.pythonhosted.org/packages/55/4b/173beafc99e99e7276ce008ef060b704471e75124c826bc5e2092815da37/backports_zstd-1.3.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f52523d2bdada29e653261abdc9cfcecd9e5500d305708b7e37caddb24909d4e", size = 506378 }, - { url = "https://files.pythonhosted.org/packages/df/c8/3f12a411d9a99d262cdb37b521025eecc2aa7e4a93277be3f4f4889adb74/backports_zstd-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3321d00beaacbd647252a7f581c1e1cdbdbda2407f2addce4bfb10e8e404b7c7", size = 476201 }, - { url = "https://files.pythonhosted.org/packages/43/dc/73c090e4a2d5671422512e1b6d276ca6ea0cc0c45ec4634789106adc0d66/backports_zstd-1.3.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:88f94d238ef36c639c0ae17cf41054ce103da9c4d399c6a778ce82690d9f4919", size = 581659 }, - { url = "https://files.pythonhosted.org/packages/08/4f/11bfcef534aa2bf3f476f52130217b45337f334d8a287edb2e06744a6515/backports_zstd-1.3.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97d8c78fe20c7442c810adccfd5e3ea6a4e6f4f1fa4c73da2bc083260ebead17", size = 640388 }, - { url = "https://files.pythonhosted.org/packages/71/17/8faea426d4f49b63238bdfd9f211a9f01c862efe0d756d3abeb84265a4e2/backports_zstd-1.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eefda80c3dbfbd924f1c317e7b0543d39304ee645583cb58bae29e19f42948ed", size = 494173 }, - { url = "https://files.pythonhosted.org/packages/ba/9d/901f19ac90f3cd999bdcfb6edb4d7b4dc383dfba537f06f533fc9ac4777b/backports_zstd-1.3.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ab5d3b5a54a674f4f6367bb9e0914063f22cd102323876135e9cc7a8f14f17e", size = 568628 }, - { url = "https://files.pythonhosted.org/packages/60/39/4d29788590c2465a570c2fae49dbff05741d1f0c8e4a0fb2c1c310f31804/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7558fb0e8c8197c59a5f80c56bf8f56c3690c45fd62f14e9e2081661556e3e64", size = 482233 }, - { url = "https://files.pythonhosted.org/packages/d9/4b/24c7c9e8ef384b19d515a7b1644a500ceb3da3baeff6d579687da1a0f62b/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:27744870e38f017159b9c0241ea51562f94c7fefcfa4c5190fb3ec4a65a7fc63", size = 509806 }, - { url = "https://files.pythonhosted.org/packages/3f/7e/7ba1aeecf0b5859f1855c0e661b4559566b64000f0627698ebd9e83f2138/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b099750755bb74c280827c7d68de621da0f245189082ab48ff91bda0ec2db9df", size = 586037 }, - { url = "https://files.pythonhosted.org/packages/4a/1a/18f0402b36b9cfb0aea010b5df900cfd42c214f37493561dba3abac90c4e/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5434e86f2836d453ae3e19a2711449683b7e21e107686838d12a255ad256ca99", size = 566220 }, - { url = "https://files.pythonhosted.org/packages/dc/d9/44c098ab31b948bbfd909ec4ae08e1e44c5025a2d846f62991a62ab3ebea/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:407e451f64e2f357c9218f5be4e372bb6102d7ae88582d415262a9d0a4f9b625", size = 630847 }, - { url = "https://files.pythonhosted.org/packages/30/33/e74cb2cfb162d2e9e00dad8bcdf53118ca7786cfd467925d6864732f79cc/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a071f3c198c781b2df801070290b7174e3ff61875454e9df93ab7ea9ea832b", size = 498665 }, - { url = "https://files.pythonhosted.org/packages/a2/a9/67a24007c333ed22736d5cd79f1aa1d7209f09be772ff82a8fd724c1978e/backports_zstd-1.3.0-cp312-cp312-win32.whl", hash = "sha256:21a9a542ccc7958ddb51ae6e46d8ed25d585b54d0d52aaa1c8da431ea158046a", size = 288809 }, - { url = "https://files.pythonhosted.org/packages/42/24/34b816118ea913debb2ea23e71ffd0fb2e2ac738064c4ac32e3fb62c18bb/backports_zstd-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:89ea8281821123b071a06b30b80da8e4d8a2b40a4f57315a19850337a21297ac", size = 313815 }, - { url = "https://files.pythonhosted.org/packages/4e/2f/babd02c9fc4ca35376ada7c291193a208165c7be2455f0f98bc1e1243f31/backports_zstd-1.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:f6843ecb181480e423b02f60fe29e393cbc31a95fb532acdf0d3a2c87bd50ce3", size = 288927 }, - { url = "https://files.pythonhosted.org/packages/0c/7d/53e8da5950cdfc5e8fe23efd5165ce2f4fed5222f9a3292e0cdb03dd8c0d/backports_zstd-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e86e03e3661900955f01afed6c59cae9baa63574e3b66896d99b7de97eaffce9", size = 435463 }, - { url = "https://files.pythonhosted.org/packages/da/78/f98e53870f7404071a41e3d04f2ff514302eeeb3279d931d02b220f437aa/backports_zstd-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:41974dcacc9824c1effe1c8d2f9d762bcf47d265ca4581a3c63321c7b06c61f0", size = 361740 }, - { url = "https://files.pythonhosted.org/packages/6d/ed/2c64706205a944c9c346d95c17f632d4e3468db3ce60efb6f5caa7c0dcae/backports_zstd-1.3.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:3090a97738d6ce9545d3ca5446df43370928092a962cbc0153e5445a947e98ed", size = 505651 }, - { url = "https://files.pythonhosted.org/packages/7b/7b/22998f691dc6e0c7e6fa81d611eb4b1f6a72fb27327f322366d4a7ca8fb3/backports_zstd-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddc874638abf03ea1ff3b0525b4a26a8d0adf7cb46a448c3449f08e4abc276b3", size = 475859 }, - { url = "https://files.pythonhosted.org/packages/0b/78/0cde898339a339530e5f932634872d2d64549969535447a48d3b98959e11/backports_zstd-1.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db609e57b8ed88b3472930c87e93c08a4bbd5ffeb94608cd9c7c6f0ac0e166c6", size = 581339 }, - { url = "https://files.pythonhosted.org/packages/e2/1d/e0973e0eebe678c12c146473af2c54cda8a3e63b179785ca1a20727ad69c/backports_zstd-1.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f13033a3dd95f323c067199f2e61b4589a7880188ef4ef356c7ffbdb78a9f11", size = 642182 }, - { url = "https://files.pythonhosted.org/packages/82/a2/ac67e79e137eb98aead66c7162bafe3cffcb82ef9cdeb6367ec18d88fbce/backports_zstd-1.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c4c7bcda5619a754726e7f5b391827f5efbe4bed8e62e9ec7490d42bff18aa6", size = 490807 }, - { url = "https://files.pythonhosted.org/packages/0f/e9/3514b1d065801ae7dce05246e9389003ed8fb1d7c3d71f85aa07a80f41e6/backports_zstd-1.3.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884a94c40f27affe986f394f219a4fd3cbbd08e1cff2e028d29d467574cd266e", size = 566103 }, - { url = "https://files.pythonhosted.org/packages/1b/03/10ddb54cbf032e5fe390c0776d3392611b1fc772d6c3cb5a9bcdff4f915f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497f5765126f11a5b3fd8fedfdae0166d1dd867e7179b8148370a3313d047197", size = 481614 }, - { url = "https://files.pythonhosted.org/packages/5c/13/21efa7f94c41447f43aee1563b05fc540a235e61bce4597754f6c11c2e97/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a6ff6769948bb29bba07e1c2e8582d5a9765192a366108e42d6581a458475881", size = 509207 }, - { url = "https://files.pythonhosted.org/packages/de/e7/12da9256d9e49e71030f0ff75e9f7c258e76091a4eaf5b5f414409be6a57/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1623e5bff1acd9c8ef90d24fc548110f20df2d14432bfe5de59e76fc036824ef", size = 585765 }, - { url = "https://files.pythonhosted.org/packages/24/bf/59ca9cb4e7be1e59331bb792e8ef1331828efe596b1a2f8cbbc4e3f70d75/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:622c28306dcc429c8f2057fc4421d5722b1f22968d299025b35d71b50cfd4e03", size = 563852 }, - { url = "https://files.pythonhosted.org/packages/7c/ee/5a3eaed9a73bdf2c35dc0c7adc0616a99588e0de28f5ab52f3e0caaaa96f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09a2785e410ed2e812cb39b684ef5eb55083a5897bfd0e6f5de3bbd2c6345f70", size = 632549 }, - { url = "https://files.pythonhosted.org/packages/75/b9/c823633afc48a1ac56d6ad34289c8f51b0234685142531bfa8197ca91777/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ade1f4127fdbe36a02f8067d75aa79c1ea1c8a306bf63c7b818bb7b530e1beaa", size = 495104 }, - { url = "https://files.pythonhosted.org/packages/a3/8f/6f7030f18fa7307f87b0f57108a50a3a540b6350e2486d1739c0567629a3/backports_zstd-1.3.0-cp313-cp313-win32.whl", hash = "sha256:668e6fb1805b825cb7504c71436f7b28d4d792bb2663ee901ec9a2bb15804437", size = 288447 }, - { url = "https://files.pythonhosted.org/packages/a2/82/b1df1bbbe4e6d3ffd364d0bcffdeb6c4361115c1eccd91238dbdd0c07fec/backports_zstd-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:385bdadf0ea8fe6ba780a95e4c7d7f018db7bafdd630932f0f9f0fad05d608ff", size = 313664 }, - { url = "https://files.pythonhosted.org/packages/45/0f/60918fe4d3f2881de8f4088d73be4837df9e4c6567594109d355a2d548b6/backports_zstd-1.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:4321a8a367537224b3559fe7aeb8012b98aea2a60a737e59e51d86e2e856fe0a", size = 288678 }, - { url = "https://files.pythonhosted.org/packages/a7/b9/35f423c0bcd85020d5e7be6ab8d7517843e3e4441071beb5c3bd8c5216cb/backports_zstd-1.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:10057d66fa4f0a7d3f6419ffb84b4fe61088da572e3ac4446134a1c8089e4166", size = 436155 }, - { url = "https://files.pythonhosted.org/packages/f6/14/e504daea24e8916f14ecbc223c354b558d8410cfc846606668ab91d96b38/backports_zstd-1.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4abf29d706ba05f658ca0247eb55675bcc00e10f12bca15736e45b05f1f2d2dc", size = 362436 }, - { url = "https://files.pythonhosted.org/packages/c4/f7/06e178dbab7edb88c2872aebd68b54137e07a169eba1aeedf614014f7036/backports_zstd-1.3.0-cp313-cp313t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:127b0d73c745b0684da3d95c31c0939570810dad8967dfe8231eea8f0e047b2f", size = 507600 }, - { url = "https://files.pythonhosted.org/packages/3e/f1/2ce499b81c4389d6fa1eeea7e76f6e0bad48effdbb239da7cbcdaaf24b76/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0205ef809fb38bb5ca7f59fa03993596f918768b9378fb7fbd8a68889a6ce028", size = 475496 }, - { url = "https://files.pythonhosted.org/packages/18/1e/c82a586f2866aabf3a601a521af3c58756d83d98b724fda200016ac5e7e2/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c389b667b0b07915781aa28beabf2481f11a6062a1a081873c4c443b98601a7", size = 580919 }, - { url = "https://files.pythonhosted.org/packages/1b/a3/eb5d9b7c4cb69d1b8ccd011abe244ba6815693b70bed07ed4b77ddda4535/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8e7ac5ef693d49d6fb35cd7bbb98c4762cfea94a8bd2bf2ab112027004f70b11", size = 639913 }, - { url = "https://files.pythonhosted.org/packages/11/2c/7296b99df79d9f31174a99c81c1964a32de8996ce2b3068f5bc66b413615/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d5543945aae2a76a850b23f283249424f535de6a622d6002957b7d971e6a36d", size = 494800 }, - { url = "https://files.pythonhosted.org/packages/f9/fc/b8ae6e104ba72d20cd5f9dfd9baee36675e89c81d432434927967114f30f/backports_zstd-1.3.0-cp313-cp313t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38be15ebce82737deda2c9410c1f942f1df9da74121049243a009810432db75", size = 570396 }, - { url = "https://files.pythonhosted.org/packages/30/56/60a7a9de7a5bc951ea1106358b413c95183c93480394f3abc541313c8679/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3e3f58c76f4730607a4e0130d629173aa114ae72a5c8d3d5ad94e1bf51f18d8", size = 481980 }, - { url = "https://files.pythonhosted.org/packages/4b/bb/93fc1e8e81b8ecba58b0e53a14f7b44375cf837db6354410998f0c4cb6ff/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:b808bf889722d889b792f7894e19c1f904bb0e9092d8c0eb0787b939b08bad9a", size = 511358 }, - { url = "https://files.pythonhosted.org/packages/ae/0f/b165c2a6080d22306975cd86ce97270208493f31a298867e343110570370/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f7be27d56f2f715bcd252d0c65c232146d8e1e039c7e2835b8a3ad3dc88bc508", size = 585492 }, - { url = "https://files.pythonhosted.org/packages/26/76/85b4bde76e982b24a7eb57a2fb9868807887bef4d2114a3654a6530a67ef/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:cbe341c7fcc723893663a37175ba859328b907a4e6d2d40a4c26629cc55efb67", size = 568309 }, - { url = "https://files.pythonhosted.org/packages/83/64/9490667827a320766fb883f358a7c19171fdc04f19ade156a8c341c36967/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b4116a9e12dfcd834dd9132cf6a94657bf0d328cba5b295f26de26ea0ae1adc8", size = 630518 }, - { url = "https://files.pythonhosted.org/packages/ea/43/258587233b728bbff457bdb0c52b3e08504c485a8642b3daeb0bdd5a76bc/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1049e804cc8754290b24dab383d4d6ed0b7f794ad8338813ddcb3907d15a89d0", size = 499429 }, - { url = "https://files.pythonhosted.org/packages/32/04/cfab76878f360f124dbb533779e1e4603c801a0f5ada72ae5c742b7c4d7d/backports_zstd-1.3.0-cp313-cp313t-win32.whl", hash = "sha256:7d3f0f2499d2049ec53d2674c605a4b3052c217cc7ee49c05258046411685adc", size = 289389 }, - { url = "https://files.pythonhosted.org/packages/cb/ff/dbcfb6c9c922ab6d98f3d321e7d0c7b34ecfa26f3ca71d930fe1ef639737/backports_zstd-1.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:eb2f8fab0b1ea05148394cb34a9e543a43477178765f2d6e7c84ed332e34935e", size = 314776 }, - { url = "https://files.pythonhosted.org/packages/01/4b/82e4baae3117806639fe1c693b1f2f7e6133a7cefd1fa2e38018c8edcd68/backports_zstd-1.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c66ad9eb5bfbe28c2387b7fc58ddcdecfb336d6e4e60bcba1694a906c1f21a6c", size = 289315 }, - { url = "https://files.pythonhosted.org/packages/95/b7/e843d32122f25d9568e75d1e7a29c00eae5e5728015604f3f6d02259b3a5/backports_zstd-1.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3ab0d5632b84eff4355c42a04668cfe6466f7d390890f718978582bd1ff36949", size = 409771 }, - { url = "https://files.pythonhosted.org/packages/fa/a5/d6a897d4b91732f54b4506858f1da65d7a5b2dc0dbe36a23992a64f09f5a/backports_zstd-1.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b97cea95dbb1a97c02afd718155fad93f747815069722107a429804c355e206", size = 339289 }, - { url = "https://files.pythonhosted.org/packages/3f/b0/f0ce566ec221b284508eebbf574a779ba4a8932830db6ea03b6176f336a2/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:477895f2642f9397aeba69618df2c91d7f336e02df83d1e623ac37c5d3a5115e", size = 420335 }, - { url = "https://files.pythonhosted.org/packages/62/6d/bf55652c84c79b2565d3087265bcb097719540a313dee16359a54d83ab4e/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:330172aaf5fd3bfa53f49318abc6d1d4238cb043c384cf71f7b8f0fe2fb7ce31", size = 393880 }, - { url = "https://files.pythonhosted.org/packages/be/e0/d1feebb70ffeb150e2891c6f09700079f4a60085ebc67529eb1ca72fb5c2/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32974e71eff15897ed3f8b7766a753d9f3197ea4f1c9025d80f8de099a691b99", size = 413840 }, - { url = "https://files.pythonhosted.org/packages/36/28/3b7be27ae51e418d3a724bbc4cb7fea77b6bd38b5007e333a56b0cb165c8/backports_zstd-1.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:993e3a34eaba5928a2065545e34bf75c65b9c34ecb67e43d5ef49b16cc182077", size = 299685 }, - { url = "https://files.pythonhosted.org/packages/9a/d9/8c9c246e5ea79a4f45d551088b11b61f2dc7efcdc5dbe6df3be84a506e0c/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:968167d29f012cee7b112ad031a8925e484e97e99288e55e4d62962c3a1013e3", size = 409666 }, - { url = "https://files.pythonhosted.org/packages/a4/4f/a55b33c314ca8c9074e99daab54d04c5d212070ae7dbc435329baf1b139e/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8f6fc7d62b71083b574193dd8fb3a60e6bb34880cc0132aad242943af301f7a", size = 339199 }, - { url = "https://files.pythonhosted.org/packages/9d/13/ce31bd048b1c88d0f65d7af60b6cf89cfbed826c7c978f0ebca9a8a71cfc/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:e0f2eca6aac280fdb77991ad3362487ee91a7fb064ad40043fb5a0bf5a376943", size = 420332 }, - { url = "https://files.pythonhosted.org/packages/cf/80/c0cdbc533d0037b57248588403a3afb050b2a83b8c38aa608e31b3a4d600/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:676eb5e177d4ef528cf3baaeea4fffe05f664e4dd985d3ac06960ef4619c81a9", size = 393879 }, - { url = "https://files.pythonhosted.org/packages/0f/38/c97428867cac058ed196ccaeddfdf82ecd43b8a65965f2950a6e7547e77a/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:199eb9bd8aca6a9d489c41a682fad22c587dffe57b613d0fe6d492d0d38ce7c5", size = 413842 }, - { url = "https://files.pythonhosted.org/packages/8d/ec/6247be6536668fe1c7dfae3eaa9c94b00b956b716957c0fc986ba78c3cc4/backports_zstd-1.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2524bd6777a828d5e7ccd7bd1a57f9e7007ae654fc2bd1bc1a207f6428674e4a", size = 299684 }, + { url = "https://files.pythonhosted.org/packages/76/70/766f6ebbb9db2ed75951f0a671ee15931dc69278c84d9f09b08dd6b67c3e/backports_zstd-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a2db17a6d9bf6b4dc223b3f6414aa9db6d1afe9de9bff61d582c2934ca456a0", size = 435664, upload-time = "2025-12-29T17:25:29.201Z" }, + { url = "https://files.pythonhosted.org/packages/55/f8/7b3fad9c6ee5ff3bcd7c941586675007330197ff4a388f01c73198ecc8bb/backports_zstd-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7f16b98ba81780a9517ce6c493e1aea9b7d72de2b1efa08375136c270e1ecba", size = 362060, upload-time = "2025-12-29T17:25:30.94Z" }, + { url = "https://files.pythonhosted.org/packages/68/9e/cad0f508ed7c3fbd07398f22b5bf25aa0523fcf56c84c3def642909e80ae/backports_zstd-1.3.0-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:1124a169a647671ccb4654a0ef1d0b42d6735c45ce3d0adf609df22fb1f099db", size = 505958, upload-time = "2025-12-29T17:25:32.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/96dc55c043b0d86e53ae9608b496196936244c1ecf7e95cdf66d0dbc0f23/backports_zstd-1.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8410fda08b36202d01ab4503f6787c763898888cb1a48c19fce94711563d3ee3", size = 475571, upload-time = "2025-12-29T17:25:33.9Z" }, + { url = "https://files.pythonhosted.org/packages/20/48/d9c8c8c2a5ac57fc5697f1945254af31407b0c5f80335a175a7c215b4118/backports_zstd-1.3.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab139d1fc0e91a697e82fa834e6404098802f11b6035607174776173ded9a2cc", size = 581199, upload-time = "2025-12-29T17:25:35.566Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/7fe70d2d39ed39e26a6c6f6c1dd229f1ab889500d5c90b17527702b1a21e/backports_zstd-1.3.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f3115d203f387f77c23b5461fb6678d282d4f276f9f39298ad242b00120afc7", size = 640846, upload-time = "2025-12-29T17:25:36.86Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d8/5b8580469e70b72402212885bf19b9d31eaf23549b602e0c294edf380e25/backports_zstd-1.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:116f65cce84e215dfac0414924b051faf8d29dc7188cf3944dd1e5be8dd15a32", size = 491061, upload-time = "2025-12-29T17:25:38.721Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/17a752263fccd1ba24184b7e89c14cd31553d512e2e5b065f38e63a0ba86/backports_zstd-1.3.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04def169e4a9ae291298124da4e097c6d6545d0e93164f934b716da04d24630a", size = 565071, upload-time = "2025-12-29T17:25:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/df23d3fe664b2497ab2ec01dc012cb9304e7d568c67f50b1b324fb2d8cbb/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:481b586291ef02a250f03d4c31a37c9881e5e93556568abbd20ca1ad720d443f", size = 481518, upload-time = "2025-12-29T17:25:41.925Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cd/e50dd85fde890c5d79e1ed5dc241f1c45f87b6c12571fdb60add57f2ee66/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0290979eea67f7275fa42d5859cc5bea94f2c08cca6bc36396673476773d2bad", size = 509464, upload-time = "2025-12-29T17:25:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bb/e429156e4b834837fe78b4f32ed512491aea39415444420c79ccd3aa0526/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:01c699d8c803dc9f9c9d6ede21b75ec99f45c3b411821011692befca538928cb", size = 585563, upload-time = "2025-12-29T17:25:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/95/c0/1a0d245325827242aefe76f4f3477ec183b996b8db5105698564f8303481/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2c662912cfc1a5ebd1d2162ac651549d58bd3c97a8096130ec13c703fca355f2", size = 562889, upload-time = "2025-12-29T17:25:46.576Z" }, + { url = "https://files.pythonhosted.org/packages/93/42/126b2bc7540a15452c3ebdf190ebfea8a8644e29b22f4e10e2a6aa2389e4/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3180c8eb085396928e9946167e610aa625922b82c3e2263c5f17000556370168", size = 631423, upload-time = "2025-12-29T17:25:47.81Z" }, + { url = "https://files.pythonhosted.org/packages/dc/32/018e49657411582569032b7d1bb5d62e514aad8b44952de740ec6250588d/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5b9a8c75a294e7ffa18fc8425a763facc366435a8b442e4dffdc19fa9499a22c", size = 495122, upload-time = "2025-12-29T17:25:49.377Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9e/cdd1d2e1d3612bb90d9cf9b23bea06f2155cdafccd8b6f28a1c4d7750004/backports_zstd-1.3.0-cp310-cp310-win32.whl", hash = "sha256:845defdb172385f17123d92a00d2e952d341e9ae310bfa2410c292bf03846034", size = 288573, upload-time = "2025-12-29T17:25:51.167Z" }, + { url = "https://files.pythonhosted.org/packages/55/7c/2e9c80f08375bd14262cefa69297a926134f517c9955c0795eec5e1d470e/backports_zstd-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:43a9fea6299c801da85221e387b32d90a9ad7c62aa2a34edf525359ce5ad8f3a", size = 313506, upload-time = "2025-12-29T17:25:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5d/fa67e8174f54db44eb33498abb7f98bea4f2329e873b225391bda0113a5e/backports_zstd-1.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:df8473cb117e1316e6c6101f2724e025bd8f50af2dc009d0001c0aabfb5eb57c", size = 288688, upload-time = "2025-12-29T17:25:54.012Z" }, + { url = "https://files.pythonhosted.org/packages/ac/28/ed31a0e35feb4538a996348362051b52912d50f00d25c2d388eccef9242c/backports_zstd-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:249f90b39d3741c48620021a968b35f268ca70e35f555abeea9ff95a451f35f9", size = 435660, upload-time = "2025-12-29T17:25:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/3db362169d80442adda9dd563c4f0bb10091c8c1c9a158037f4ecd53988e/backports_zstd-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b0e71e83e46154a9d3ced6d4de9a2fea8207ee1e4832aeecf364dc125eda305c", size = 362056, upload-time = "2025-12-29T17:25:56.729Z" }, + { url = "https://files.pythonhosted.org/packages/bd/00/b67ba053a7d6f6dbe2f8a704b7d3a5e01b1d2e2e8edbc9b634f2702ef73c/backports_zstd-1.3.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:cbc6193acd21f96760c94dd71bf32b161223e8503f5277acb0a5ab54e5598957", size = 505957, upload-time = "2025-12-29T17:25:57.941Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3e/2667c0ddb53ddf28667e330bf9fe92e8e17705a481c9b698e283120565f7/backports_zstd-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1df583adc0ae84a8d13d7139f42eade6d90182b1dd3e0d28f7df3c564b9fd55d", size = 475569, upload-time = "2025-12-29T17:25:59.075Z" }, + { url = "https://files.pythonhosted.org/packages/eb/86/4052473217bd954ccdffda5f7264a0e99e7c4ecf70c0f729845c6a45fc5a/backports_zstd-1.3.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d833fc23aa3cc2e05aeffc7cfadd87b796654ad3a7fb214555cda3f1db2d4dc2", size = 581196, upload-time = "2025-12-29T17:26:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bd/064f6fdb61db3d2c473159ebc844243e650dc032de0f8208443a00127925/backports_zstd-1.3.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:142178fe981061f1d2a57c5348f2cd31a3b6397a35593e7a17dbda817b793a7f", size = 640888, upload-time = "2025-12-29T17:26:02.134Z" }, + { url = "https://files.pythonhosted.org/packages/d8/09/0822403f40932a165a4f1df289d41653683019e4fd7a86b63ed20e9b6177/backports_zstd-1.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5eed0a09a163f3a8125a857cb031be87ed052e4a47bc75085ed7fca786e9bb5b", size = 491100, upload-time = "2025-12-29T17:26:03.418Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a3/f5ac28d74039b7e182a780809dc66b9dbfc893186f5d5444340bba135389/backports_zstd-1.3.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60aa483fef5843749e993dde01229e5eedebca8c283023d27d6bf6800d1d4ce3", size = 565071, upload-time = "2025-12-29T17:26:05.022Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ac/50209aeb92257a642ee987afa1e61d5b6731ab6bf0bff70905856e5aede6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ea0886c1b619773544546e243ed73f6d6c2b1ae3c00c904ccc9903a352d731e1", size = 481519, upload-time = "2025-12-29T17:26:06.255Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/b06f64199fb4b2e9437cedbf96d0155ca08aeec35fe81d41065acd44762e/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5e137657c830a5ce99be40a1d713eb1d246bae488ada28ff0666ac4387aebdd5", size = 509465, upload-time = "2025-12-29T17:26:07.602Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/2c365196e61c8fffbbc930ffd69f1ada7aa1c7210857b3e565031c787ac6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:94048c8089755e482e4b34608029cf1142523a625873c272be2b1c9253871a72", size = 585552, upload-time = "2025-12-29T17:26:08.911Z" }, + { url = "https://files.pythonhosted.org/packages/93/8d/c2c4f448bb6b6c9df17410eaedce415e8db0eb25b60d09a3d22a98294d09/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d339c1ec40485e97e600eb9a285fb13169dbf44c5094b945788a62f38b96e533", size = 562893, upload-time = "2025-12-29T17:26:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/2110d4d39115130f7514cbbcec673a885f4052bb68d15e41bc96a7558856/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8aeee9210c54cf8bf83f4d263a6d0d6e7a0298aeb5a14a0a95e90487c5c3157c", size = 631462, upload-time = "2025-12-29T17:26:11.99Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a8/d64b59ae0714fdace14e43873f794eff93613e35e3e85eead33a4f44cd80/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba7114a3099e5ea05cbb46568bd0e08bca2ca11e12c6a7b563a24b86b2b4a67f", size = 495125, upload-time = "2025-12-29T17:26:13.218Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/bcff0a091fcf27172c57ae463e49d8dec6dc31e01d7e7bf1ae3aad9c3566/backports_zstd-1.3.0-cp311-cp311-win32.whl", hash = "sha256:08dfdfb85da5915383bfae680b6ac10ab5769ab22e690f9a854320720011ae8e", size = 288664, upload-time = "2025-12-29T17:26:14.791Z" }, + { url = "https://files.pythonhosted.org/packages/28/1a/379061e2abf8c3150ad51c1baab9ac723e01cf7538860a6a74c48f8b73ee/backports_zstd-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8aac2e7cdcc8f310c16f98a0062b48d0a081dbb82862794f4f4f5bdafde30a4", size = 313633, upload-time = "2025-12-29T17:26:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/e7/eca40858883029fc716660106069b23253e2ec5fd34e86b4101c8cfe864b/backports_zstd-1.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:440ef1be06e82dc0d69dbb57177f2ce98bbd2151013ee7e551e2f2b54caa6120", size = 288814, upload-time = "2025-12-29T17:26:17.571Z" }, + { url = "https://files.pythonhosted.org/packages/72/d4/356da49d3053f4bc50e71a8535631b57bc9ca4e8c6d2442e073e0ab41c44/backports_zstd-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f4a292e357f3046d18766ce06d990ccbab97411708d3acb934e63529c2ea7786", size = 435972, upload-time = "2025-12-29T17:26:18.752Z" }, + { url = "https://files.pythonhosted.org/packages/30/8f/dbe389e60c7e47af488520f31a4aa14028d66da5bf3c60d3044b571eb906/backports_zstd-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb4c386f38323698991b38edcc9c091d46d4713f5df02a3b5c80a28b40e289ea", size = 362124, upload-time = "2025-12-29T17:26:19.995Z" }, + { url = "https://files.pythonhosted.org/packages/55/4b/173beafc99e99e7276ce008ef060b704471e75124c826bc5e2092815da37/backports_zstd-1.3.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f52523d2bdada29e653261abdc9cfcecd9e5500d305708b7e37caddb24909d4e", size = 506378, upload-time = "2025-12-29T17:26:21.855Z" }, + { url = "https://files.pythonhosted.org/packages/df/c8/3f12a411d9a99d262cdb37b521025eecc2aa7e4a93277be3f4f4889adb74/backports_zstd-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3321d00beaacbd647252a7f581c1e1cdbdbda2407f2addce4bfb10e8e404b7c7", size = 476201, upload-time = "2025-12-29T17:26:23.047Z" }, + { url = "https://files.pythonhosted.org/packages/43/dc/73c090e4a2d5671422512e1b6d276ca6ea0cc0c45ec4634789106adc0d66/backports_zstd-1.3.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:88f94d238ef36c639c0ae17cf41054ce103da9c4d399c6a778ce82690d9f4919", size = 581659, upload-time = "2025-12-29T17:26:24.189Z" }, + { url = "https://files.pythonhosted.org/packages/08/4f/11bfcef534aa2bf3f476f52130217b45337f334d8a287edb2e06744a6515/backports_zstd-1.3.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97d8c78fe20c7442c810adccfd5e3ea6a4e6f4f1fa4c73da2bc083260ebead17", size = 640388, upload-time = "2025-12-29T17:26:25.47Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8faea426d4f49b63238bdfd9f211a9f01c862efe0d756d3abeb84265a4e2/backports_zstd-1.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eefda80c3dbfbd924f1c317e7b0543d39304ee645583cb58bae29e19f42948ed", size = 494173, upload-time = "2025-12-29T17:26:26.736Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9d/901f19ac90f3cd999bdcfb6edb4d7b4dc383dfba537f06f533fc9ac4777b/backports_zstd-1.3.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ab5d3b5a54a674f4f6367bb9e0914063f22cd102323876135e9cc7a8f14f17e", size = 568628, upload-time = "2025-12-29T17:26:28.12Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/4d29788590c2465a570c2fae49dbff05741d1f0c8e4a0fb2c1c310f31804/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7558fb0e8c8197c59a5f80c56bf8f56c3690c45fd62f14e9e2081661556e3e64", size = 482233, upload-time = "2025-12-29T17:26:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4b/24c7c9e8ef384b19d515a7b1644a500ceb3da3baeff6d579687da1a0f62b/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:27744870e38f017159b9c0241ea51562f94c7fefcfa4c5190fb3ec4a65a7fc63", size = 509806, upload-time = "2025-12-29T17:26:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7e/7ba1aeecf0b5859f1855c0e661b4559566b64000f0627698ebd9e83f2138/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b099750755bb74c280827c7d68de621da0f245189082ab48ff91bda0ec2db9df", size = 586037, upload-time = "2025-12-29T17:26:32.201Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1a/18f0402b36b9cfb0aea010b5df900cfd42c214f37493561dba3abac90c4e/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5434e86f2836d453ae3e19a2711449683b7e21e107686838d12a255ad256ca99", size = 566220, upload-time = "2025-12-29T17:26:33.5Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d9/44c098ab31b948bbfd909ec4ae08e1e44c5025a2d846f62991a62ab3ebea/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:407e451f64e2f357c9218f5be4e372bb6102d7ae88582d415262a9d0a4f9b625", size = 630847, upload-time = "2025-12-29T17:26:35.273Z" }, + { url = "https://files.pythonhosted.org/packages/30/33/e74cb2cfb162d2e9e00dad8bcdf53118ca7786cfd467925d6864732f79cc/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a071f3c198c781b2df801070290b7174e3ff61875454e9df93ab7ea9ea832b", size = 498665, upload-time = "2025-12-29T17:26:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a9/67a24007c333ed22736d5cd79f1aa1d7209f09be772ff82a8fd724c1978e/backports_zstd-1.3.0-cp312-cp312-win32.whl", hash = "sha256:21a9a542ccc7958ddb51ae6e46d8ed25d585b54d0d52aaa1c8da431ea158046a", size = 288809, upload-time = "2025-12-29T17:26:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/42/24/34b816118ea913debb2ea23e71ffd0fb2e2ac738064c4ac32e3fb62c18bb/backports_zstd-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:89ea8281821123b071a06b30b80da8e4d8a2b40a4f57315a19850337a21297ac", size = 313815, upload-time = "2025-12-29T17:26:39.665Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2f/babd02c9fc4ca35376ada7c291193a208165c7be2455f0f98bc1e1243f31/backports_zstd-1.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:f6843ecb181480e423b02f60fe29e393cbc31a95fb532acdf0d3a2c87bd50ce3", size = 288927, upload-time = "2025-12-29T17:26:40.923Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7d/53e8da5950cdfc5e8fe23efd5165ce2f4fed5222f9a3292e0cdb03dd8c0d/backports_zstd-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e86e03e3661900955f01afed6c59cae9baa63574e3b66896d99b7de97eaffce9", size = 435463, upload-time = "2025-12-29T17:26:42.152Z" }, + { url = "https://files.pythonhosted.org/packages/da/78/f98e53870f7404071a41e3d04f2ff514302eeeb3279d931d02b220f437aa/backports_zstd-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:41974dcacc9824c1effe1c8d2f9d762bcf47d265ca4581a3c63321c7b06c61f0", size = 361740, upload-time = "2025-12-29T17:26:43.377Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ed/2c64706205a944c9c346d95c17f632d4e3468db3ce60efb6f5caa7c0dcae/backports_zstd-1.3.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:3090a97738d6ce9545d3ca5446df43370928092a962cbc0153e5445a947e98ed", size = 505651, upload-time = "2025-12-29T17:26:44.495Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7b/22998f691dc6e0c7e6fa81d611eb4b1f6a72fb27327f322366d4a7ca8fb3/backports_zstd-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddc874638abf03ea1ff3b0525b4a26a8d0adf7cb46a448c3449f08e4abc276b3", size = 475859, upload-time = "2025-12-29T17:26:45.722Z" }, + { url = "https://files.pythonhosted.org/packages/0b/78/0cde898339a339530e5f932634872d2d64549969535447a48d3b98959e11/backports_zstd-1.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db609e57b8ed88b3472930c87e93c08a4bbd5ffeb94608cd9c7c6f0ac0e166c6", size = 581339, upload-time = "2025-12-29T17:26:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1d/e0973e0eebe678c12c146473af2c54cda8a3e63b179785ca1a20727ad69c/backports_zstd-1.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f13033a3dd95f323c067199f2e61b4589a7880188ef4ef356c7ffbdb78a9f11", size = 642182, upload-time = "2025-12-29T17:26:48.545Z" }, + { url = "https://files.pythonhosted.org/packages/82/a2/ac67e79e137eb98aead66c7162bafe3cffcb82ef9cdeb6367ec18d88fbce/backports_zstd-1.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c4c7bcda5619a754726e7f5b391827f5efbe4bed8e62e9ec7490d42bff18aa6", size = 490807, upload-time = "2025-12-29T17:26:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/3514b1d065801ae7dce05246e9389003ed8fb1d7c3d71f85aa07a80f41e6/backports_zstd-1.3.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884a94c40f27affe986f394f219a4fd3cbbd08e1cff2e028d29d467574cd266e", size = 566103, upload-time = "2025-12-29T17:26:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/1b/03/10ddb54cbf032e5fe390c0776d3392611b1fc772d6c3cb5a9bcdff4f915f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497f5765126f11a5b3fd8fedfdae0166d1dd867e7179b8148370a3313d047197", size = 481614, upload-time = "2025-12-29T17:26:52.255Z" }, + { url = "https://files.pythonhosted.org/packages/5c/13/21efa7f94c41447f43aee1563b05fc540a235e61bce4597754f6c11c2e97/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a6ff6769948bb29bba07e1c2e8582d5a9765192a366108e42d6581a458475881", size = 509207, upload-time = "2025-12-29T17:26:53.496Z" }, + { url = "https://files.pythonhosted.org/packages/de/e7/12da9256d9e49e71030f0ff75e9f7c258e76091a4eaf5b5f414409be6a57/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1623e5bff1acd9c8ef90d24fc548110f20df2d14432bfe5de59e76fc036824ef", size = 585765, upload-time = "2025-12-29T17:26:54.99Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/59ca9cb4e7be1e59331bb792e8ef1331828efe596b1a2f8cbbc4e3f70d75/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:622c28306dcc429c8f2057fc4421d5722b1f22968d299025b35d71b50cfd4e03", size = 563852, upload-time = "2025-12-29T17:26:56.371Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ee/5a3eaed9a73bdf2c35dc0c7adc0616a99588e0de28f5ab52f3e0caaaa96f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09a2785e410ed2e812cb39b684ef5eb55083a5897bfd0e6f5de3bbd2c6345f70", size = 632549, upload-time = "2025-12-29T17:26:57.598Z" }, + { url = "https://files.pythonhosted.org/packages/75/b9/c823633afc48a1ac56d6ad34289c8f51b0234685142531bfa8197ca91777/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ade1f4127fdbe36a02f8067d75aa79c1ea1c8a306bf63c7b818bb7b530e1beaa", size = 495104, upload-time = "2025-12-29T17:26:58.826Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8f/6f7030f18fa7307f87b0f57108a50a3a540b6350e2486d1739c0567629a3/backports_zstd-1.3.0-cp313-cp313-win32.whl", hash = "sha256:668e6fb1805b825cb7504c71436f7b28d4d792bb2663ee901ec9a2bb15804437", size = 288447, upload-time = "2025-12-29T17:27:00.036Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/b1df1bbbe4e6d3ffd364d0bcffdeb6c4361115c1eccd91238dbdd0c07fec/backports_zstd-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:385bdadf0ea8fe6ba780a95e4c7d7f018db7bafdd630932f0f9f0fad05d608ff", size = 313664, upload-time = "2025-12-29T17:27:01.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/0f/60918fe4d3f2881de8f4088d73be4837df9e4c6567594109d355a2d548b6/backports_zstd-1.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:4321a8a367537224b3559fe7aeb8012b98aea2a60a737e59e51d86e2e856fe0a", size = 288678, upload-time = "2025-12-29T17:27:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/35f423c0bcd85020d5e7be6ab8d7517843e3e4441071beb5c3bd8c5216cb/backports_zstd-1.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:10057d66fa4f0a7d3f6419ffb84b4fe61088da572e3ac4446134a1c8089e4166", size = 436155, upload-time = "2025-12-29T17:27:03.859Z" }, + { url = "https://files.pythonhosted.org/packages/f6/14/e504daea24e8916f14ecbc223c354b558d8410cfc846606668ab91d96b38/backports_zstd-1.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4abf29d706ba05f658ca0247eb55675bcc00e10f12bca15736e45b05f1f2d2dc", size = 362436, upload-time = "2025-12-29T17:27:05.076Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f7/06e178dbab7edb88c2872aebd68b54137e07a169eba1aeedf614014f7036/backports_zstd-1.3.0-cp313-cp313t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:127b0d73c745b0684da3d95c31c0939570810dad8967dfe8231eea8f0e047b2f", size = 507600, upload-time = "2025-12-29T17:27:06.254Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f1/2ce499b81c4389d6fa1eeea7e76f6e0bad48effdbb239da7cbcdaaf24b76/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0205ef809fb38bb5ca7f59fa03993596f918768b9378fb7fbd8a68889a6ce028", size = 475496, upload-time = "2025-12-29T17:27:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/c82a586f2866aabf3a601a521af3c58756d83d98b724fda200016ac5e7e2/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c389b667b0b07915781aa28beabf2481f11a6062a1a081873c4c443b98601a7", size = 580919, upload-time = "2025-12-29T17:27:09.1Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/eb5d9b7c4cb69d1b8ccd011abe244ba6815693b70bed07ed4b77ddda4535/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8e7ac5ef693d49d6fb35cd7bbb98c4762cfea94a8bd2bf2ab112027004f70b11", size = 639913, upload-time = "2025-12-29T17:27:10.433Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/7296b99df79d9f31174a99c81c1964a32de8996ce2b3068f5bc66b413615/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d5543945aae2a76a850b23f283249424f535de6a622d6002957b7d971e6a36d", size = 494800, upload-time = "2025-12-29T17:27:11.59Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fc/b8ae6e104ba72d20cd5f9dfd9baee36675e89c81d432434927967114f30f/backports_zstd-1.3.0-cp313-cp313t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38be15ebce82737deda2c9410c1f942f1df9da74121049243a009810432db75", size = 570396, upload-time = "2025-12-29T17:27:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/56/60a7a9de7a5bc951ea1106358b413c95183c93480394f3abc541313c8679/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3e3f58c76f4730607a4e0130d629173aa114ae72a5c8d3d5ad94e1bf51f18d8", size = 481980, upload-time = "2025-12-29T17:27:14.317Z" }, + { url = "https://files.pythonhosted.org/packages/4b/bb/93fc1e8e81b8ecba58b0e53a14f7b44375cf837db6354410998f0c4cb6ff/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:b808bf889722d889b792f7894e19c1f904bb0e9092d8c0eb0787b939b08bad9a", size = 511358, upload-time = "2025-12-29T17:27:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0f/b165c2a6080d22306975cd86ce97270208493f31a298867e343110570370/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f7be27d56f2f715bcd252d0c65c232146d8e1e039c7e2835b8a3ad3dc88bc508", size = 585492, upload-time = "2025-12-29T17:27:16.986Z" }, + { url = "https://files.pythonhosted.org/packages/26/76/85b4bde76e982b24a7eb57a2fb9868807887bef4d2114a3654a6530a67ef/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:cbe341c7fcc723893663a37175ba859328b907a4e6d2d40a4c26629cc55efb67", size = 568309, upload-time = "2025-12-29T17:27:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/83/64/9490667827a320766fb883f358a7c19171fdc04f19ade156a8c341c36967/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b4116a9e12dfcd834dd9132cf6a94657bf0d328cba5b295f26de26ea0ae1adc8", size = 630518, upload-time = "2025-12-29T17:27:19.525Z" }, + { url = "https://files.pythonhosted.org/packages/ea/43/258587233b728bbff457bdb0c52b3e08504c485a8642b3daeb0bdd5a76bc/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1049e804cc8754290b24dab383d4d6ed0b7f794ad8338813ddcb3907d15a89d0", size = 499429, upload-time = "2025-12-29T17:27:21.063Z" }, + { url = "https://files.pythonhosted.org/packages/32/04/cfab76878f360f124dbb533779e1e4603c801a0f5ada72ae5c742b7c4d7d/backports_zstd-1.3.0-cp313-cp313t-win32.whl", hash = "sha256:7d3f0f2499d2049ec53d2674c605a4b3052c217cc7ee49c05258046411685adc", size = 289389, upload-time = "2025-12-29T17:27:22.287Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ff/dbcfb6c9c922ab6d98f3d321e7d0c7b34ecfa26f3ca71d930fe1ef639737/backports_zstd-1.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:eb2f8fab0b1ea05148394cb34a9e543a43477178765f2d6e7c84ed332e34935e", size = 314776, upload-time = "2025-12-29T17:27:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/01/4b/82e4baae3117806639fe1c693b1f2f7e6133a7cefd1fa2e38018c8edcd68/backports_zstd-1.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c66ad9eb5bfbe28c2387b7fc58ddcdecfb336d6e4e60bcba1694a906c1f21a6c", size = 289315, upload-time = "2025-12-29T17:27:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/e843d32122f25d9568e75d1e7a29c00eae5e5728015604f3f6d02259b3a5/backports_zstd-1.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3ab0d5632b84eff4355c42a04668cfe6466f7d390890f718978582bd1ff36949", size = 409771, upload-time = "2025-12-29T17:27:48.869Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a5/d6a897d4b91732f54b4506858f1da65d7a5b2dc0dbe36a23992a64f09f5a/backports_zstd-1.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b97cea95dbb1a97c02afd718155fad93f747815069722107a429804c355e206", size = 339289, upload-time = "2025-12-29T17:27:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b0/f0ce566ec221b284508eebbf574a779ba4a8932830db6ea03b6176f336a2/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:477895f2642f9397aeba69618df2c91d7f336e02df83d1e623ac37c5d3a5115e", size = 420335, upload-time = "2025-12-29T17:27:51.455Z" }, + { url = "https://files.pythonhosted.org/packages/62/6d/bf55652c84c79b2565d3087265bcb097719540a313dee16359a54d83ab4e/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:330172aaf5fd3bfa53f49318abc6d1d4238cb043c384cf71f7b8f0fe2fb7ce31", size = 393880, upload-time = "2025-12-29T17:27:52.869Z" }, + { url = "https://files.pythonhosted.org/packages/be/e0/d1feebb70ffeb150e2891c6f09700079f4a60085ebc67529eb1ca72fb5c2/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32974e71eff15897ed3f8b7766a753d9f3197ea4f1c9025d80f8de099a691b99", size = 413840, upload-time = "2025-12-29T17:27:54.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/28/3b7be27ae51e418d3a724bbc4cb7fea77b6bd38b5007e333a56b0cb165c8/backports_zstd-1.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:993e3a34eaba5928a2065545e34bf75c65b9c34ecb67e43d5ef49b16cc182077", size = 299685, upload-time = "2025-12-29T17:27:56.149Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d9/8c9c246e5ea79a4f45d551088b11b61f2dc7efcdc5dbe6df3be84a506e0c/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:968167d29f012cee7b112ad031a8925e484e97e99288e55e4d62962c3a1013e3", size = 409666, upload-time = "2025-12-29T17:27:57.37Z" }, + { url = "https://files.pythonhosted.org/packages/a4/4f/a55b33c314ca8c9074e99daab54d04c5d212070ae7dbc435329baf1b139e/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8f6fc7d62b71083b574193dd8fb3a60e6bb34880cc0132aad242943af301f7a", size = 339199, upload-time = "2025-12-29T17:27:58.542Z" }, + { url = "https://files.pythonhosted.org/packages/9d/13/ce31bd048b1c88d0f65d7af60b6cf89cfbed826c7c978f0ebca9a8a71cfc/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:e0f2eca6aac280fdb77991ad3362487ee91a7fb064ad40043fb5a0bf5a376943", size = 420332, upload-time = "2025-12-29T17:28:00.332Z" }, + { url = "https://files.pythonhosted.org/packages/cf/80/c0cdbc533d0037b57248588403a3afb050b2a83b8c38aa608e31b3a4d600/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:676eb5e177d4ef528cf3baaeea4fffe05f664e4dd985d3ac06960ef4619c81a9", size = 393879, upload-time = "2025-12-29T17:28:01.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/38/c97428867cac058ed196ccaeddfdf82ecd43b8a65965f2950a6e7547e77a/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:199eb9bd8aca6a9d489c41a682fad22c587dffe57b613d0fe6d492d0d38ce7c5", size = 413842, upload-time = "2025-12-29T17:28:03.113Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ec/6247be6536668fe1c7dfae3eaa9c94b00b956b716957c0fc986ba78c3cc4/backports_zstd-1.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2524bd6777a828d5e7ccd7bd1a57f9e7007ae654fc2bd1bc1a207f6428674e4a", size = 299684, upload-time = "2025-12-29T17:28:04.856Z" }, ] [[package]] @@ -159,76 +159,76 @@ dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721 }, + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] [[package]] name = "blinker" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] [[package]] name = "brotli" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632 } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/10/a090475284fc4a71aed40a96f32e44a7fe5bda39687353dd977720b211b6/brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", size = 863089 }, - { url = "https://files.pythonhosted.org/packages/03/41/17416630e46c07ac21e378c3464815dd2e120b441e641bc516ac32cc51d2/brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", size = 445442 }, - { url = "https://files.pythonhosted.org/packages/24/31/90cc06584deb5d4fcafc0985e37741fc6b9717926a78674bbb3ce018957e/brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", size = 1532658 }, - { url = "https://files.pythonhosted.org/packages/62/17/33bf0c83bcbc96756dfd712201d87342732fad70bb3472c27e833a44a4f9/brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", size = 1631241 }, - { url = "https://files.pythonhosted.org/packages/48/10/f47854a1917b62efe29bc98ac18e5d4f71df03f629184575b862ef2e743b/brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", size = 1424307 }, - { url = "https://files.pythonhosted.org/packages/e4/b7/f88eb461719259c17483484ea8456925ee057897f8e64487d76e24e5e38d/brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", size = 1488208 }, - { url = "https://files.pythonhosted.org/packages/26/59/41bbcb983a0c48b0b8004203e74706c6b6e99a04f3c7ca6f4f41f364db50/brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d", size = 1597574 }, - { url = "https://files.pythonhosted.org/packages/8e/e6/8c89c3bdabbe802febb4c5c6ca224a395e97913b5df0dff11b54f23c1788/brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", size = 1492109 }, - { url = "https://files.pythonhosted.org/packages/ed/9a/4b19d4310b2dbd545c0c33f176b0528fa68c3cd0754e34b2f2bcf56548ae/brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", size = 334461 }, - { url = "https://files.pythonhosted.org/packages/ac/39/70981d9f47705e3c2b95c0847dfa3e7a37aa3b7c6030aedc4873081ed005/brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", size = 369035 }, - { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110 }, - { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438 }, - { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420 }, - { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619 }, - { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014 }, - { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661 }, - { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150 }, - { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505 }, - { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451 }, - { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035 }, - { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543 }, - { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288 }, - { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071 }, - { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913 }, - { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762 }, - { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494 }, - { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302 }, - { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913 }, - { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362 }, - { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115 }, - { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523 }, - { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289 }, - { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076 }, - { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880 }, - { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737 }, - { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440 }, - { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313 }, - { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945 }, - { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368 }, - { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116 }, - { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080 }, - { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453 }, - { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168 }, - { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098 }, - { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861 }, - { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594 }, - { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455 }, - { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164 }, - { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280 }, - { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639 }, + { url = "https://files.pythonhosted.org/packages/64/10/a090475284fc4a71aed40a96f32e44a7fe5bda39687353dd977720b211b6/brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", size = 863089, upload-time = "2025-11-05T18:38:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/03/41/17416630e46c07ac21e378c3464815dd2e120b441e641bc516ac32cc51d2/brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", size = 445442, upload-time = "2025-11-05T18:38:02.434Z" }, + { url = "https://files.pythonhosted.org/packages/24/31/90cc06584deb5d4fcafc0985e37741fc6b9717926a78674bbb3ce018957e/brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", size = 1532658, upload-time = "2025-11-05T18:38:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/62/17/33bf0c83bcbc96756dfd712201d87342732fad70bb3472c27e833a44a4f9/brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", size = 1631241, upload-time = "2025-11-05T18:38:04.582Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/f47854a1917b62efe29bc98ac18e5d4f71df03f629184575b862ef2e743b/brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", size = 1424307, upload-time = "2025-11-05T18:38:05.587Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b7/f88eb461719259c17483484ea8456925ee057897f8e64487d76e24e5e38d/brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", size = 1488208, upload-time = "2025-11-05T18:38:06.613Z" }, + { url = "https://files.pythonhosted.org/packages/26/59/41bbcb983a0c48b0b8004203e74706c6b6e99a04f3c7ca6f4f41f364db50/brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d", size = 1597574, upload-time = "2025-11-05T18:38:07.838Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e6/8c89c3bdabbe802febb4c5c6ca224a395e97913b5df0dff11b54f23c1788/brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", size = 1492109, upload-time = "2025-11-05T18:38:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/4b19d4310b2dbd545c0c33f176b0528fa68c3cd0754e34b2f2bcf56548ae/brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", size = 334461, upload-time = "2025-11-05T18:38:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/ac/39/70981d9f47705e3c2b95c0847dfa3e7a37aa3b7c6030aedc4873081ed005/brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", size = 369035, upload-time = "2025-11-05T18:38:11.827Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, ] [[package]] @@ -238,40 +238,40 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156 } +sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046 }, - { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126 }, - { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983 }, - { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047 }, - { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652 }, - { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608 }, - { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257 }, - { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838 }, - { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337 }, - { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026 }, - { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265 }, - { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710 }, - { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787 }, - { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108 }, + { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, ] [[package]] name = "cachelib" version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/69/0b5c1259e12fbcf5c2abe5934b5c0c1294ec0f845e2b4b2a51a91d79a4fb/cachelib-0.13.0.tar.gz", hash = "sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48", size = 34418 } +sdist = { url = "https://files.pythonhosted.org/packages/1d/69/0b5c1259e12fbcf5c2abe5934b5c0c1294ec0f845e2b4b2a51a91d79a4fb/cachelib-0.13.0.tar.gz", hash = "sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48", size = 34418, upload-time = "2024-04-13T14:18:27.782Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/42/960fc9896ddeb301716fdd554bab7941c35fb90a1dc7260b77df3366f87f/cachelib-0.13.0-py3-none-any.whl", hash = "sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516", size = 20914 }, + { url = "https://files.pythonhosted.org/packages/9b/42/960fc9896ddeb301716fdd554bab7941c35fb90a1dc7260b77df3366f87f/cachelib-0.13.0-py3-none-any.whl", hash = "sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516", size = 20914, upload-time = "2024-04-13T14:18:26.361Z" }, ] [[package]] name = "certifi" version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -281,193 +281,193 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283 }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504 }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811 }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402 }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217 }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079 }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475 }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829 }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211 }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036 }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184 }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790 }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182 }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329 }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230 }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890 }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930 }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109 }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684 }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785 }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055 }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502 }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295 }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145 }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884 }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343 }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174 }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805 }, - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705 }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419 }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901 }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742 }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061 }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239 }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173 }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841 }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304 }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455 }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036 }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739 }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277 }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819 }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281 }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843 }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627 }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008 }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303 }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282 }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595 }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986 }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711 }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036 }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998 }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056 }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537 }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176 }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723 }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085 }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819 }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915 }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234 }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042 }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706 }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727 }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882 }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860 }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564 }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276 }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238 }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189 }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352 }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024 }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869 }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541 }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634 }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384 }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133 }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257 }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851 }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393 }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251 }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609 }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014 }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979 }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238 }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110 }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824 }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103 }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194 }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827 }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168 }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018 }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] @@ -477,27 +477,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856 } +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379 }, + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "colorcet" version = "3.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/c3/ae78e10b7139d6b7ce080d2e81d822715763336aa4229720f49cb3b3e15b/colorcet-3.1.0.tar.gz", hash = "sha256:2921b3cd81a2288aaf2d63dbc0ce3c26dcd882e8c389cc505d6886bf7aa9a4eb", size = 2183107 } +sdist = { url = "https://files.pythonhosted.org/packages/5f/c3/ae78e10b7139d6b7ce080d2e81d822715763336aa4229720f49cb3b3e15b/colorcet-3.1.0.tar.gz", hash = "sha256:2921b3cd81a2288aaf2d63dbc0ce3c26dcd882e8c389cc505d6886bf7aa9a4eb", size = 2183107, upload-time = "2024-02-29T19:15:42.976Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/c6/9963d588cc3d75d766c819e0377a168ef83cf3316a92769971527a1ad1de/colorcet-3.1.0-py3-none-any.whl", hash = "sha256:2a7d59cc8d0f7938eeedd08aad3152b5319b4ba3bcb7a612398cc17a384cb296", size = 260286 }, + { url = "https://files.pythonhosted.org/packages/c6/c6/9963d588cc3d75d766c819e0377a168ef83cf3316a92769971527a1ad1de/colorcet-3.1.0-py3-none-any.whl", hash = "sha256:2a7d59cc8d0f7938eeedd08aad3152b5319b4ba3bcb7a612398cc17a384cb296", size = 260286, upload-time = "2024-02-29T19:15:40.494Z" }, ] [[package]] @@ -508,56 +508,56 @@ dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652 } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869 }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492 }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670 }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275 }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402 }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985 }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652 }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805 }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883 }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756 }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244 }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868 }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504 }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363 }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671 }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551 }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887 }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354 }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845 }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641 }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749 }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942 }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079 }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999 }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191 }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782 }, - { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227 }, - { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332 }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618 }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628 }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405 }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715 }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400 }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634 }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233 }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955 }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888 }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961 }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696 }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256 }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001 }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985 }, - { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879 }, - { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700 }, - { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982 }, - { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115 }, - { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479 }, - { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829 }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]] @@ -575,9 +575,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/3a/322a583ca3479ad2dc6a8bfbaf7f8e4d01e16cc5030d8e7b45445bc22502/dash-3.4.0.tar.gz", hash = "sha256:3944beb32000ee8b22cd7fbb33545a0a43e25916c63aa41ba59ee5611997815e", size = 7581177 } +sdist = { url = "https://files.pythonhosted.org/packages/7a/3a/322a583ca3479ad2dc6a8bfbaf7f8e4d01e16cc5030d8e7b45445bc22502/dash-3.4.0.tar.gz", hash = "sha256:3944beb32000ee8b22cd7fbb33545a0a43e25916c63aa41ba59ee5611997815e", size = 7581177, upload-time = "2026-01-20T20:46:47.43Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/2e/8fa7d095f7ab28649ece149118ccbde8286be52037b02ab02fbe52c34601/dash-3.4.0-py3-none-any.whl", hash = "sha256:62b1c2eca3cfbe05f5e6ed8666e2c9a204aa08e2ceef89f01ce9bcccb3c18e95", size = 7921864 }, + { url = "https://files.pythonhosted.org/packages/87/2e/8fa7d095f7ab28649ece149118ccbde8286be52037b02ab02fbe52c34601/dash-3.4.0-py3-none-any.whl", hash = "sha256:62b1c2eca3cfbe05f5e6ed8666e2c9a204aa08e2ceef89f01ce9bcccb3c18e95", size = 7921864, upload-time = "2026-01-20T20:46:36.088Z" }, ] [package.optional-dependencies] @@ -605,9 +605,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/d4/5b7da808ff5acb3a6ca702f504d8ef05bc7d4c475b18dadefd783b1120c3/dash_bootstrap_components-2.0.4.tar.gz", hash = "sha256:c3206c0923774bbc6a6ddaa7822b8d9aa5326b0d3c1e7cd795cc975025fe2484", size = 115599 } +sdist = { url = "https://files.pythonhosted.org/packages/cc/d4/5b7da808ff5acb3a6ca702f504d8ef05bc7d4c475b18dadefd783b1120c3/dash_bootstrap_components-2.0.4.tar.gz", hash = "sha256:c3206c0923774bbc6a6ddaa7822b8d9aa5326b0d3c1e7cd795cc975025fe2484", size = 115599, upload-time = "2025-08-20T19:42:09.449Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/38/1efeec8b4d741c09ccd169baf8a00c07a0176b58e418d4cd0c30dffedd22/dash_bootstrap_components-2.0.4-py3-none-any.whl", hash = "sha256:767cf0084586c1b2b614ccf50f79fe4525fdbbf8e3a161ed60016e584a14f5d1", size = 204044 }, + { url = "https://files.pythonhosted.org/packages/d6/38/1efeec8b4d741c09ccd169baf8a00c07a0176b58e418d4cd0c30dffedd22/dash_bootstrap_components-2.0.4-py3-none-any.whl", hash = "sha256:767cf0084586c1b2b614ccf50f79fe4525fdbbf8e3a161ed60016e584a14f5d1", size = 204044, upload-time = "2025-08-20T19:42:07.928Z" }, ] [[package]] @@ -625,9 +625,9 @@ dependencies = [ { name = "more-itertools", version = "10.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pydantic", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/a4/b8787d12b4d196ea8beb87fc345af03aac4ed42b9b09d98ab28d7403687f/dash_extensions-1.0.20.tar.gz", hash = "sha256:e32d1ac779ef04f3362a0183e993a25f61caab75e1a828a328a8dad83f6bbe0c", size = 1463075 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/a4/b8787d12b4d196ea8beb87fc345af03aac4ed42b9b09d98ab28d7403687f/dash_extensions-1.0.20.tar.gz", hash = "sha256:e32d1ac779ef04f3362a0183e993a25f61caab75e1a828a328a8dad83f6bbe0c", size = 1463075, upload-time = "2025-02-01T15:09:20.69Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/86/fd0e454ec7a6de151566e07e495a629805cfc858891d256efbe196f038bb/dash_extensions-1.0.20-py3-none-any.whl", hash = "sha256:e4e6a2c103ef8ca629e3f3b91703e587c38ecdc3aedb9fc3793182bbb2d64c23", size = 1487796 }, + { url = "https://files.pythonhosted.org/packages/5d/86/fd0e454ec7a6de151566e07e495a629805cfc858891d256efbe196f038bb/dash_extensions-1.0.20-py3-none-any.whl", hash = "sha256:e4e6a2c103ef8ca629e3f3b91703e587c38ecdc3aedb9fc3793182bbb2d64c23", size = 1487796, upload-time = "2025-02-01T15:09:18.764Z" }, ] [[package]] @@ -653,9 +653,9 @@ dependencies = [ { name = "more-itertools", version = "11.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pydantic", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/c2/feb284e47ba3a1f759d6c95003b83522c970690cf6baf3a21096c4f36263/dash_extensions-2.0.5.tar.gz", hash = "sha256:5ee9b444d5e4318637019fcd513857f9b4f488569963fa270f77ee07802e5405", size = 998632 } +sdist = { url = "https://files.pythonhosted.org/packages/80/c2/feb284e47ba3a1f759d6c95003b83522c970690cf6baf3a21096c4f36263/dash_extensions-2.0.5.tar.gz", hash = "sha256:5ee9b444d5e4318637019fcd513857f9b4f488569963fa270f77ee07802e5405", size = 998632, upload-time = "2026-03-05T12:02:58.782Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/8b/cff245f82a869ca897545c2adbeacaddd6cc7851f7c9f9832337b4cdd976/dash_extensions-2.0.5-py3-none-any.whl", hash = "sha256:e672f693c799614c6ca802c2ca07f0c083042647983114d42b7c5fd1c7fa25e6", size = 1019240 }, + { url = "https://files.pythonhosted.org/packages/15/8b/cff245f82a869ca897545c2adbeacaddd6cc7851f7c9f9832337b4cdd976/dash_extensions-2.0.5-py3-none-any.whl", hash = "sha256:e672f693c799614c6ca802c2ca07f0c083042647983114d42b7c5fd1c7fa25e6", size = 1019240, upload-time = "2026-03-05T12:02:57.095Z" }, ] [[package]] @@ -668,9 +668,9 @@ resolution-markers = [ dependencies = [ { name = "dash", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/a3/f38986785869b67721984a2c131372bbd720e5a00bb4769e417f3a8c7000/dash_leaflet-1.0.15.tar.gz", hash = "sha256:79a3e19b0be9a5cf23873a0cd73153de224cbd756ddb30e2ffcee9ba23875e40", size = 243078 } +sdist = { url = "https://files.pythonhosted.org/packages/a0/a3/f38986785869b67721984a2c131372bbd720e5a00bb4769e417f3a8c7000/dash_leaflet-1.0.15.tar.gz", hash = "sha256:79a3e19b0be9a5cf23873a0cd73153de224cbd756ddb30e2ffcee9ba23875e40", size = 243078, upload-time = "2024-01-11T18:06:16.963Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/6a/3a75338d37c45c10a552c73ccd68f7ede9c9b36fa9ef39dbf710a662ccc0/dash_leaflet-1.0.15-py3-none-any.whl", hash = "sha256:d7d06228a868bcf11120e1f7cb95db64c29c412b743686a9a8e1b30ac54a794e", size = 285497 }, + { url = "https://files.pythonhosted.org/packages/26/6a/3a75338d37c45c10a552c73ccd68f7ede9c9b36fa9ef39dbf710a662ccc0/dash_leaflet-1.0.15-py3-none-any.whl", hash = "sha256:d7d06228a868bcf11120e1f7cb95db64c29c412b743686a9a8e1b30ac54a794e", size = 285497, upload-time = "2024-01-11T18:06:14.433Z" }, ] [[package]] @@ -697,27 +697,27 @@ dependencies = [ { name = "protobuf", marker = "python_full_version >= '3.11'" }, { name = "pyyaml", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/19/4fd29ef8db9c6a5a73a34714ccca8349c8825c836adb98451c46457bd2e8/dash_leaflet-1.1.3.tar.gz", hash = "sha256:fc7c6da57b71715cfda5ac187ead2ac600f54ccc2cfac8c396f2f426f75f40ce", size = 250248 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/19/4fd29ef8db9c6a5a73a34714ccca8349c8825c836adb98451c46457bd2e8/dash_leaflet-1.1.3.tar.gz", hash = "sha256:fc7c6da57b71715cfda5ac187ead2ac600f54ccc2cfac8c396f2f426f75f40ce", size = 250248, upload-time = "2025-05-15T17:52:41.108Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/1a/c1d6576f93f8e67d71c0218e1c9493055cb724b035615d930ee08ca34419/dash_leaflet-1.1.3-py3-none-any.whl", hash = "sha256:8c8d761c0eb6817812afab1b0ca21fb2272515901795db581f7154f208051f37", size = 294625 }, + { url = "https://files.pythonhosted.org/packages/1c/1a/c1d6576f93f8e67d71c0218e1c9493055cb724b035615d930ee08ca34419/dash_leaflet-1.1.3-py3-none-any.whl", hash = "sha256:8c8d761c0eb6817812afab1b0ca21fb2272515901795db581f7154f208051f37", size = 294625, upload-time = "2025-05-15T17:52:39.401Z" }, ] [[package]] name = "dash-svg" version = "0.0.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/b1/a17887bd3243925d8801319a5f9a6df4379426c43f54d39652facbab10a2/dash_svg-0.0.12.tar.gz", hash = "sha256:a7115bf437d770b822c2dd53b9d9a981210619b7d17c925cbee04905fc761b4e", size = 671120 } +sdist = { url = "https://files.pythonhosted.org/packages/26/b1/a17887bd3243925d8801319a5f9a6df4379426c43f54d39652facbab10a2/dash_svg-0.0.12.tar.gz", hash = "sha256:a7115bf437d770b822c2dd53b9d9a981210619b7d17c925cbee04905fc761b4e", size = 671120, upload-time = "2023-02-27T08:10:44.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/76/55b690616441568d5cad9eb34d4d3e968b39bdaeeb6850dc5106bd8289a6/dash_svg-0.0.12-py3-none-any.whl", hash = "sha256:0ec26694ccfc4f6c65d20254d76b2cd3680975ca5fbe0056956c3992551c727b", size = 810151 }, + { url = "https://files.pythonhosted.org/packages/f4/76/55b690616441568d5cad9eb34d4d3e968b39bdaeeb6850dc5106bd8289a6/dash_svg-0.0.12-py3-none-any.whl", hash = "sha256:0ec26694ccfc4f6c65d20254d76b2cd3680975ca5fbe0056956c3992551c727b", size = 810151, upload-time = "2023-02-27T08:10:41.587Z" }, ] [[package]] name = "dash-testing-stub" version = "0.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/59/28/25fb4d73646bc00eea150cd7e3d43355e4e25c48fa7648b414b5cf1a88f7/dash-testing-stub-0.0.2.tar.gz", hash = "sha256:0a98f7da9fe41dd3a37d781bc1d5672319448fdf98e47fd867aff2123171a357", size = 2037 } +sdist = { url = "https://files.pythonhosted.org/packages/59/28/25fb4d73646bc00eea150cd7e3d43355e4e25c48fa7648b414b5cf1a88f7/dash-testing-stub-0.0.2.tar.gz", hash = "sha256:0a98f7da9fe41dd3a37d781bc1d5672319448fdf98e47fd867aff2123171a357", size = 2037, upload-time = "2023-02-09T16:52:04.629Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/67/56de4c8eab3932150fb046250dc25f7c5d772958352e78c0192bb2b328e8/dash_testing_stub-0.0.2-py3-none-any.whl", hash = "sha256:a44d530a77e1ede9c6528be4b5951f34c6109b419a09f2691422375ffa7d09de", size = 2557 }, + { url = "https://files.pythonhosted.org/packages/42/67/56de4c8eab3932150fb046250dc25f7c5d772958352e78c0192bb2b328e8/dash_testing_stub-0.0.2-py3-none-any.whl", hash = "sha256:a44d530a77e1ede9c6528be4b5951f34c6109b419a09f2691422375ffa7d09de", size = 2557, upload-time = "2023-02-09T16:52:03.165Z" }, ] [[package]] @@ -730,9 +730,9 @@ resolution-markers = [ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/31/d8f4236648e72139627d732df6724e6a83dc5dcd06c4078e301979f0b96e/dataclass-wizard-0.30.1.tar.gz", hash = "sha256:f382daab1c9aca258fe47fed089a495b69da736bde2dc7ff61c5440de2233f38", size = 201591 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/31/d8f4236648e72139627d732df6724e6a83dc5dcd06c4078e301979f0b96e/dataclass-wizard-0.30.1.tar.gz", hash = "sha256:f382daab1c9aca258fe47fed089a495b69da736bde2dc7ff61c5440de2233f38", size = 201591, upload-time = "2024-11-26T03:33:08.486Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/c4/1698067427e6fd5bff407cafe5ba1b3cf109b92e2b064d2e4b4f73379bc3/dataclass_wizard-0.30.1-py2.py3-none-any.whl", hash = "sha256:bf4af012d4fc04511efcc2be52024589faec150795bec5517b56d4ab131e2d1d", size = 114331 }, + { url = "https://files.pythonhosted.org/packages/21/c4/1698067427e6fd5bff407cafe5ba1b3cf109b92e2b064d2e4b4f73379bc3/dataclass_wizard-0.30.1-py2.py3-none-any.whl", hash = "sha256:bf4af012d4fc04511efcc2be52024589faec150795bec5517b56d4ab131e2d1d", size = 114331, upload-time = "2024-11-26T03:33:06.79Z" }, ] [[package]] @@ -753,14 +753,14 @@ resolution-markers = [ dependencies = [ { name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/ea/6b2811092feecbe9d672e3641196ac5fcbec6664074da5dec8b9fd9b9059/dataclass_wizard-0.39.1.tar.gz", hash = "sha256:1679948ed7c62103f40b34df97d03b35e6b2ad50f58173fdbe30074e2e4730f2", size = 361190 } +sdist = { url = "https://files.pythonhosted.org/packages/36/ea/6b2811092feecbe9d672e3641196ac5fcbec6664074da5dec8b9fd9b9059/dataclass_wizard-0.39.1.tar.gz", hash = "sha256:1679948ed7c62103f40b34df97d03b35e6b2ad50f58173fdbe30074e2e4730f2", size = 361190, upload-time = "2026-01-06T03:30:57.054Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/a0/a221942b3fbbafaea4e211744d298366b6a9712c1aa336b05fc1c865ac0c/dataclass_wizard-0.39.1-py3-none-any.whl", hash = "sha256:3324e59eca705882eb34e2b3989b2beadd8c2b523e6269d4002cf1a4a5bf703b", size = 215344 }, + { url = "https://files.pythonhosted.org/packages/27/a0/a221942b3fbbafaea4e211744d298366b6a9712c1aa336b05fc1c865ac0c/dataclass_wizard-0.39.1-py3-none-any.whl", hash = "sha256:3324e59eca705882eb34e2b3989b2beadd8c2b523e6269d4002cf1a4a5bf703b", size = 215344, upload-time = "2026-01-06T03:30:54.915Z" }, ] [[package]] name = "decp-info" -version = "2.7.7" +version = "2.7.8" source = { virtual = "." } dependencies = [ { name = "dash", extra = ["compress"] }, @@ -831,69 +831,69 @@ dev = [ name = "dill" version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315 } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019 }, + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] [[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 } +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 }, + { 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 = "duckdb" version = "1.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/66/744b4931b799a42f8cb9bc7a6f169e7b8e51195b62b246db407fd90bf15f/duckdb-1.5.2.tar.gz", hash = "sha256:638da0d5102b6cb6f7d47f83d0600708ac1d3cb46c5e9aaabc845f9ba4d69246", size = 18017166 } +sdist = { url = "https://files.pythonhosted.org/packages/0c/66/744b4931b799a42f8cb9bc7a6f169e7b8e51195b62b246db407fd90bf15f/duckdb-1.5.2.tar.gz", hash = "sha256:638da0d5102b6cb6f7d47f83d0600708ac1d3cb46c5e9aaabc845f9ba4d69246", size = 18017166, upload-time = "2026-04-13T11:30:09.065Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/00/03b96203d9bf4ff8637de4d42adeca5b43342a5050f656eccce1e69d6879/duckdb-1.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:63bf8687feefeed51adf45fa3b062ab8b1b1c350492b7518491b86bae68b1da1", size = 30017339 }, - { url = "https://files.pythonhosted.org/packages/01/f4/2f4af0233489fc92822ff6021a2a4e05f7cd75fa1a352a163967fbeeab22/duckdb-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84b193aca20565dedb3172de15f843c659c3a6c773bf14843a9bd781c850e7db", size = 15945057 }, - { url = "https://files.pythonhosted.org/packages/34/0a/d41ee8cdeb63cf12f2ee9e6c8e17cc8bacff6468013be703e44fd2a22efa/duckdb-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5596bbfc31b1b259db69c8d847b42d036ce2c4804f9ccb28f9fc46a16de7bc53", size = 14199133 }, - { url = "https://files.pythonhosted.org/packages/11/39/4da08139b109d7f84b12ecca202a5adfff5b1b20970c01bd82dc09d86a59/duckdb-1.5.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8dbd7e31e5dc157bfe8803fa7d2652336265c6c19926c5a4a9b40f8222868d08", size = 19285501 }, - { url = "https://files.pythonhosted.org/packages/3c/cc/10a542561634408cbae951a836e645dda784ddc48eaa2ee72701a2992a8e/duckdb-1.5.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9cd5e71702d446613750405cde03f66ed268f4c321da071b0472759dad19536", size = 21392488 }, - { url = "https://files.pythonhosted.org/packages/1b/61/e9015ee2117f86c2e8396ad66b85c8338b2ecdc9a20eb5b099a537cf3c6a/duckdb-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:ce17670bb392ea1b3650537db02bd720908776b5b95f6d2472d31a7de59d1dc1", size = 13096311 }, - { url = "https://files.pythonhosted.org/packages/9a/b0/d13e7e396d86c245290b3e93f692a2d27c2fe99f857aaf9205003c00c978/duckdb-1.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7f69164b048e498b9e9140a24343108a5ae5f17bfb3485185f55fdf9b1aa924d", size = 30020978 }, - { url = "https://files.pythonhosted.org/packages/70/7b/ae1ec7f516394aa55501d1949af1f731be8d9d7433f0acc3f4632a0ba484/duckdb-1.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:81fc4fbf0b5e25840b39ba2a10b78c6953c0314d5d0434191e7898f34ab1bba3", size = 15947821 }, - { url = "https://files.pythonhosted.org/packages/8a/a5/cae0105e01a85f85ead61723bb42dab14c2f8ec49f91e67a2372c02574a4/duckdb-1.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56d38b3c4e0ef2abb58898d0fd423933999ed535c45e75e9d9f72e1d5fed69b8", size = 14201656 }, - { url = "https://files.pythonhosted.org/packages/50/db/46c57e8813ac33762bddc9545610ed648751c5b6a379abf2dc6035505ce4/duckdb-1.5.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:376856066c65ccd55fcb3a380bbe33a71ce089fc4623d229ffc6e82251afdb6d", size = 19285181 }, - { url = "https://files.pythonhosted.org/packages/dc/a2/67694010693ec8c8c975e6991f48ef886d35ecbdaa2f287234882a403c21/duckdb-1.5.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c69907354ffee94ba8cf782daf0480dab7557f21ce27fffa6c0ea8f74ed4b8e2", size = 21394852 }, - { url = "https://files.pythonhosted.org/packages/52/9f/2b1618c5a93949a70dcf105293db7e27bb2b2cc4aeb1ff46b806f430ec81/duckdb-1.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:d9b4f5430bf4f05d4c0dc4c55c75def3a5af4be0343be20fa2bfc577343fbfc9", size = 13095526 }, - { url = "https://files.pythonhosted.org/packages/b8/e9/cb39e0d94a32f5333e819112fd01439a31f541f9c56a31b66f9bd209704b/duckdb-1.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:2323c1195c10fb2bb982fc0218c730b43d1b92a355d61e68e3c5f3ac9d44c34f", size = 13946215 }, - { url = "https://files.pythonhosted.org/packages/41/de/ebe66bbe78125fc610f4fd415447a65349d94245950f3b3dfb31d028af02/duckdb-1.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e6495b00cad16888384119842797c49316a96ae1cb132bb03856d980d95afee1", size = 30064950 }, - { url = "https://files.pythonhosted.org/packages/2d/8a/3e25b5d03bcf1fb99d189912f8ce92b1db4f9c8778e1b1f55745973a855a/duckdb-1.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d72b8856b1839d35648f38301b058f6232f4d36b463fe4dc8f4d3fdff2df1a2e", size = 15969113 }, - { url = "https://files.pythonhosted.org/packages/19/bb/58001f0815002b1a93431bf907f77854085c7d049b83d521814a07b9db0b/duckdb-1.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2a1de4f4d454b8c97aec546c82003fc834d3422ce4bc6a19902f3462ef293bed", size = 14224774 }, - { url = "https://files.pythonhosted.org/packages/d3/2f/a7f0de9509d1cef35608aeb382919041cdd70f58c173865c3da6a0d87979/duckdb-1.5.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce0b8141a10d37ecef729c45bc41d334854013f4389f1488bd6035c5579aaac1", size = 19313510 }, - { url = "https://files.pythonhosted.org/packages/26/78/eb1e064ea8b9df3b87b167bfd7a407b2f615a4291e06cba756727adfa06c/duckdb-1.5.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99ef73a277c8921bc0a1f16dee38d924484251d9cfd20951748c20fcd5ed855", size = 21429692 }, - { url = "https://files.pythonhosted.org/packages/5b/12/05b0c47d14839925c5e35b79081d918ca82e3f236bb724a6f58409dd5291/duckdb-1.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:8d599758b4e48bf12e18c9b960cf491d219f0c4972d19a45489c05cc5ab36f83", size = 13107594 }, - { url = "https://files.pythonhosted.org/packages/0b/2c/80558a82b236e044330e84a154b96aacddb343316b479f3d49be03ea11cb/duckdb-1.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:fc85a5dbcbe6eccac1113c72370d1d3aacfdd49198d63950bdf7d8638a307f00", size = 13927537 }, - { url = "https://files.pythonhosted.org/packages/98/f2/e3d742808f138d374be4bb516fade3d1f33749b813650810ab7885cdc363/duckdb-1.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4420b3f47027a7849d0e1815532007f377fa95ee5810b47ea717d35525c12f79", size = 30064879 }, - { url = "https://files.pythonhosted.org/packages/72/0d/f3dc1cf97e1267ca15e4307d456f96ce583961f0703fd75e62b2ad8d64fa/duckdb-1.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb42e6ed543902e14eae647850da24103a89f0bc2587dec5601b1c1f213bd2ed", size = 15969327 }, - { url = "https://files.pythonhosted.org/packages/b1/e0/d5418def53ae4e05a63075705ff44ed5af5a1a5932627eb2b600c5df1c93/duckdb-1.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98c0535cd6d901f61a5ea3c2e26a1fd28482953d794deb183daf568e3aa5dda6", size = 14225107 }, - { url = "https://files.pythonhosted.org/packages/16/a7/15aaa59dbecc35e9711980fcdbf525b32a52470b32d18ef678193a146213/duckdb-1.5.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:486c862bf7f163c0110b6d85b3e5c031d224a671cca468f12ebb1d3a348f6b39", size = 19313433 }, - { url = "https://files.pythonhosted.org/packages/bd/21/d903cc63a5140c822b7b62b373a87dc557e60c29b321dfb435061c5e67cf/duckdb-1.5.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70631c847ca918ee710ec874241b00cf9d2e5be90762cbb2a0389f17823c08f7", size = 21429837 }, - { url = "https://files.pythonhosted.org/packages/e3/0a/b770d1f60c70597302130d6247f418549b7094251a02348fbaf1c7e147ae/duckdb-1.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:52a21823f3fbb52f0f0e5425e20b07391ad882464b955879499b5ff0b45a376b", size = 13107699 }, - { url = "https://files.pythonhosted.org/packages/d9/cf/e200fe431d700962d1a908d2ce89f53ccee1cc8db260174ae663ba09686b/duckdb-1.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:411ad438bd4140f189a10e7f515781335962c5d18bd07837dc6d202e3985253d", size = 13927646 }, - { url = "https://files.pythonhosted.org/packages/83/a1/f6286c67726cc1ea60a6e3c0d9fbc66527dde24ae089a51bbe298b13ca78/duckdb-1.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6b0fe75c148000f060aa1a27b293cacc0ea08cc1cad724fbf2143d56070a3785", size = 30078598 }, - { url = "https://files.pythonhosted.org/packages/de/6a/59febb02f21a4a5c6b0b0099ef7c965fdd5e61e4904cf813809bb792e35f/duckdb-1.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35579b8e3a064b5eaf15b0eafc558056a13f79a0a62e34cc4baf57119daecfec", size = 15975120 }, - { url = "https://files.pythonhosted.org/packages/09/70/ce750854d37bb5a45cccbb2c3cb04df4af56aea8fc30a2499bb643b4a9c0/duckdb-1.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ea58ff5b0880593a280cf5511734b17711b32ee1f58b47d726e8600848358160", size = 14227762 }, - { url = "https://files.pythonhosted.org/packages/28/dc/ad45ac3c0b6c4687dc649e8f6cf01af1c8b0443932a39b2abb4ebcb3babd/duckdb-1.5.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef461bca07313412dc09961c4a4757a851f56b95ac01c58fac6007632b7b94f2", size = 19315668 }, - { url = "https://files.pythonhosted.org/packages/cc/b1/1464f468d2e5813f5808de95df9d3113a645a5bfa2ffcaecbc542ddae272/duckdb-1.5.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be37680ddb380015cb37318e378c53511c45c4f0d8fac5599d22b7d092b9217a", size = 21434056 }, - { url = "https://files.pythonhosted.org/packages/ce/32/6673607e024722473fa7aafdd29c0e3dd231dd528f6cd8b5797fbeeb229d/duckdb-1.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:0b291786014df1133f8f18b9df4d004484613146e858d71a21791e0fcca16cf4", size = 13633667 }, - { url = "https://files.pythonhosted.org/packages/7a/e3/9d34173ec068631faea3ea6e73050700729363e7e33306a9a3218e5cdc61/duckdb-1.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:c9f3e0b71b8a50fccfb42794899285d9d318ce2503782b9dd54868e5ecd0ad31", size = 14402513 }, + { url = "https://files.pythonhosted.org/packages/a3/00/03b96203d9bf4ff8637de4d42adeca5b43342a5050f656eccce1e69d6879/duckdb-1.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:63bf8687feefeed51adf45fa3b062ab8b1b1c350492b7518491b86bae68b1da1", size = 30017339, upload-time = "2026-04-13T11:28:36.134Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/2f4af0233489fc92822ff6021a2a4e05f7cd75fa1a352a163967fbeeab22/duckdb-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84b193aca20565dedb3172de15f843c659c3a6c773bf14843a9bd781c850e7db", size = 15945057, upload-time = "2026-04-13T11:28:39.21Z" }, + { url = "https://files.pythonhosted.org/packages/34/0a/d41ee8cdeb63cf12f2ee9e6c8e17cc8bacff6468013be703e44fd2a22efa/duckdb-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5596bbfc31b1b259db69c8d847b42d036ce2c4804f9ccb28f9fc46a16de7bc53", size = 14199133, upload-time = "2026-04-13T11:28:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/4da08139b109d7f84b12ecca202a5adfff5b1b20970c01bd82dc09d86a59/duckdb-1.5.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8dbd7e31e5dc157bfe8803fa7d2652336265c6c19926c5a4a9b40f8222868d08", size = 19285501, upload-time = "2026-04-13T11:28:44.208Z" }, + { url = "https://files.pythonhosted.org/packages/3c/cc/10a542561634408cbae951a836e645dda784ddc48eaa2ee72701a2992a8e/duckdb-1.5.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9cd5e71702d446613750405cde03f66ed268f4c321da071b0472759dad19536", size = 21392488, upload-time = "2026-04-13T11:28:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/1b/61/e9015ee2117f86c2e8396ad66b85c8338b2ecdc9a20eb5b099a537cf3c6a/duckdb-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:ce17670bb392ea1b3650537db02bd720908776b5b95f6d2472d31a7de59d1dc1", size = 13096311, upload-time = "2026-04-13T11:28:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b0/d13e7e396d86c245290b3e93f692a2d27c2fe99f857aaf9205003c00c978/duckdb-1.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7f69164b048e498b9e9140a24343108a5ae5f17bfb3485185f55fdf9b1aa924d", size = 30020978, upload-time = "2026-04-13T11:28:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/70/7b/ae1ec7f516394aa55501d1949af1f731be8d9d7433f0acc3f4632a0ba484/duckdb-1.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:81fc4fbf0b5e25840b39ba2a10b78c6953c0314d5d0434191e7898f34ab1bba3", size = 15947821, upload-time = "2026-04-13T11:28:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a5/cae0105e01a85f85ead61723bb42dab14c2f8ec49f91e67a2372c02574a4/duckdb-1.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56d38b3c4e0ef2abb58898d0fd423933999ed535c45e75e9d9f72e1d5fed69b8", size = 14201656, upload-time = "2026-04-13T11:28:58.316Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/46c57e8813ac33762bddc9545610ed648751c5b6a379abf2dc6035505ce4/duckdb-1.5.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:376856066c65ccd55fcb3a380bbe33a71ce089fc4623d229ffc6e82251afdb6d", size = 19285181, upload-time = "2026-04-13T11:29:01.041Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/67694010693ec8c8c975e6991f48ef886d35ecbdaa2f287234882a403c21/duckdb-1.5.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c69907354ffee94ba8cf782daf0480dab7557f21ce27fffa6c0ea8f74ed4b8e2", size = 21394852, upload-time = "2026-04-13T11:29:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/52/9f/2b1618c5a93949a70dcf105293db7e27bb2b2cc4aeb1ff46b806f430ec81/duckdb-1.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:d9b4f5430bf4f05d4c0dc4c55c75def3a5af4be0343be20fa2bfc577343fbfc9", size = 13095526, upload-time = "2026-04-13T11:29:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/cb39e0d94a32f5333e819112fd01439a31f541f9c56a31b66f9bd209704b/duckdb-1.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:2323c1195c10fb2bb982fc0218c730b43d1b92a355d61e68e3c5f3ac9d44c34f", size = 13946215, upload-time = "2026-04-13T11:29:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/ebe66bbe78125fc610f4fd415447a65349d94245950f3b3dfb31d028af02/duckdb-1.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e6495b00cad16888384119842797c49316a96ae1cb132bb03856d980d95afee1", size = 30064950, upload-time = "2026-04-13T11:29:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/3e25b5d03bcf1fb99d189912f8ce92b1db4f9c8778e1b1f55745973a855a/duckdb-1.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d72b8856b1839d35648f38301b058f6232f4d36b463fe4dc8f4d3fdff2df1a2e", size = 15969113, upload-time = "2026-04-13T11:29:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/58001f0815002b1a93431bf907f77854085c7d049b83d521814a07b9db0b/duckdb-1.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2a1de4f4d454b8c97aec546c82003fc834d3422ce4bc6a19902f3462ef293bed", size = 14224774, upload-time = "2026-04-13T11:29:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2f/a7f0de9509d1cef35608aeb382919041cdd70f58c173865c3da6a0d87979/duckdb-1.5.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce0b8141a10d37ecef729c45bc41d334854013f4389f1488bd6035c5579aaac1", size = 19313510, upload-time = "2026-04-13T11:29:19.574Z" }, + { url = "https://files.pythonhosted.org/packages/26/78/eb1e064ea8b9df3b87b167bfd7a407b2f615a4291e06cba756727adfa06c/duckdb-1.5.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99ef73a277c8921bc0a1f16dee38d924484251d9cfd20951748c20fcd5ed855", size = 21429692, upload-time = "2026-04-13T11:29:22.575Z" }, + { url = "https://files.pythonhosted.org/packages/5b/12/05b0c47d14839925c5e35b79081d918ca82e3f236bb724a6f58409dd5291/duckdb-1.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:8d599758b4e48bf12e18c9b960cf491d219f0c4972d19a45489c05cc5ab36f83", size = 13107594, upload-time = "2026-04-13T11:29:25.43Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2c/80558a82b236e044330e84a154b96aacddb343316b479f3d49be03ea11cb/duckdb-1.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:fc85a5dbcbe6eccac1113c72370d1d3aacfdd49198d63950bdf7d8638a307f00", size = 13927537, upload-time = "2026-04-13T11:29:27.842Z" }, + { url = "https://files.pythonhosted.org/packages/98/f2/e3d742808f138d374be4bb516fade3d1f33749b813650810ab7885cdc363/duckdb-1.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4420b3f47027a7849d0e1815532007f377fa95ee5810b47ea717d35525c12f79", size = 30064879, upload-time = "2026-04-13T11:29:30.763Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/f3dc1cf97e1267ca15e4307d456f96ce583961f0703fd75e62b2ad8d64fa/duckdb-1.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb42e6ed543902e14eae647850da24103a89f0bc2587dec5601b1c1f213bd2ed", size = 15969327, upload-time = "2026-04-13T11:29:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e0/d5418def53ae4e05a63075705ff44ed5af5a1a5932627eb2b600c5df1c93/duckdb-1.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98c0535cd6d901f61a5ea3c2e26a1fd28482953d794deb183daf568e3aa5dda6", size = 14225107, upload-time = "2026-04-13T11:29:35.882Z" }, + { url = "https://files.pythonhosted.org/packages/16/a7/15aaa59dbecc35e9711980fcdbf525b32a52470b32d18ef678193a146213/duckdb-1.5.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:486c862bf7f163c0110b6d85b3e5c031d224a671cca468f12ebb1d3a348f6b39", size = 19313433, upload-time = "2026-04-13T11:29:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/bd/21/d903cc63a5140c822b7b62b373a87dc557e60c29b321dfb435061c5e67cf/duckdb-1.5.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70631c847ca918ee710ec874241b00cf9d2e5be90762cbb2a0389f17823c08f7", size = 21429837, upload-time = "2026-04-13T11:29:41.135Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0a/b770d1f60c70597302130d6247f418549b7094251a02348fbaf1c7e147ae/duckdb-1.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:52a21823f3fbb52f0f0e5425e20b07391ad882464b955879499b5ff0b45a376b", size = 13107699, upload-time = "2026-04-13T11:29:43.905Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/e200fe431d700962d1a908d2ce89f53ccee1cc8db260174ae663ba09686b/duckdb-1.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:411ad438bd4140f189a10e7f515781335962c5d18bd07837dc6d202e3985253d", size = 13927646, upload-time = "2026-04-13T11:29:46.598Z" }, + { url = "https://files.pythonhosted.org/packages/83/a1/f6286c67726cc1ea60a6e3c0d9fbc66527dde24ae089a51bbe298b13ca78/duckdb-1.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6b0fe75c148000f060aa1a27b293cacc0ea08cc1cad724fbf2143d56070a3785", size = 30078598, upload-time = "2026-04-13T11:29:49.828Z" }, + { url = "https://files.pythonhosted.org/packages/de/6a/59febb02f21a4a5c6b0b0099ef7c965fdd5e61e4904cf813809bb792e35f/duckdb-1.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35579b8e3a064b5eaf15b0eafc558056a13f79a0a62e34cc4baf57119daecfec", size = 15975120, upload-time = "2026-04-13T11:29:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/09/70/ce750854d37bb5a45cccbb2c3cb04df4af56aea8fc30a2499bb643b4a9c0/duckdb-1.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ea58ff5b0880593a280cf5511734b17711b32ee1f58b47d726e8600848358160", size = 14227762, upload-time = "2026-04-13T11:29:55.564Z" }, + { url = "https://files.pythonhosted.org/packages/28/dc/ad45ac3c0b6c4687dc649e8f6cf01af1c8b0443932a39b2abb4ebcb3babd/duckdb-1.5.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef461bca07313412dc09961c4a4757a851f56b95ac01c58fac6007632b7b94f2", size = 19315668, upload-time = "2026-04-13T11:29:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b1/1464f468d2e5813f5808de95df9d3113a645a5bfa2ffcaecbc542ddae272/duckdb-1.5.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be37680ddb380015cb37318e378c53511c45c4f0d8fac5599d22b7d092b9217a", size = 21434056, upload-time = "2026-04-13T11:30:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/ce/32/6673607e024722473fa7aafdd29c0e3dd231dd528f6cd8b5797fbeeb229d/duckdb-1.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:0b291786014df1133f8f18b9df4d004484613146e858d71a21791e0fcca16cf4", size = 13633667, upload-time = "2026-04-13T11:30:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e3/9d34173ec068631faea3ea6e73050700729363e7e33306a9a3218e5cdc61/duckdb-1.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:c9f3e0b71b8a50fccfb42794899285d9d318ce2503782b9dd54868e5ecd0ad31", size = 14402513, upload-time = "2026-04-13T11:30:06.609Z" }, ] [[package]] name = "editorconfig" version = "0.17.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695 } +sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695, upload-time = "2025-06-09T08:21:37.097Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360 }, + { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" }, ] [[package]] @@ -903,36 +903,36 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "fastexcel" version = "0.19.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/c8/3b09911348e9c64dbf41096d3e8f0e93c141a23990ec9f32514111bd5f55/fastexcel-0.19.0.tar.gz", hash = "sha256:216c3719ee90963bd93a0bf8c10b177233046ac975b67651152fdaedd3c99aa1", size = 60323 } +sdist = { url = "https://files.pythonhosted.org/packages/0d/c8/3b09911348e9c64dbf41096d3e8f0e93c141a23990ec9f32514111bd5f55/fastexcel-0.19.0.tar.gz", hash = "sha256:216c3719ee90963bd93a0bf8c10b177233046ac975b67651152fdaedd3c99aa1", size = 60323, upload-time = "2026-01-20T11:17:37.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/e0/3820e93ea606549cfddb8c437141dd69f2b245e74785efc8bd7511ba909d/fastexcel-0.19.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:68601072a0b4b4277c165b68f1055f88ef7ffe7ed6f08c1eeda0f0271e3f7da0", size = 3082362 }, - { url = "https://files.pythonhosted.org/packages/66/0f/b42dc09515879192919942157292912393584045fd8bad98bd92961d4c30/fastexcel-0.19.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c8a87d94445678e7e3f46a6aa39d2afaee5b88a983ec3661143a6488d8955f44", size = 2864365 }, - { url = "https://files.pythonhosted.org/packages/8e/4a/bc358b20fcff64b4c14ff7d7a0e1f797792b8b77e30ae755873c02362538/fastexcel-0.19.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e94fc1be6642555f277af792c22a9f80ec9b4d640d9690f00abb822b6d865069", size = 3186426 }, - { url = "https://files.pythonhosted.org/packages/58/ae/d2ffdc5ad14190153e2422fc90a1052a4b0c3086d24cb8ae8967575321d8/fastexcel-0.19.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:334f9f40cd68b5924a712b6c104949757a0b8ad8a7e3fa3f3fad1c1ebc00258b", size = 3365628 }, - { url = "https://files.pythonhosted.org/packages/6e/67/5f6d4e7760dc3dd8244cd124dabdd5bb7622bf1197edcc2513648847690e/fastexcel-0.19.0-cp310-abi3-win_amd64.whl", hash = "sha256:fbbdf9de79c3ef3572809bb187927c0dc5840968ffe513ea015a383024b7c6b0", size = 2905173 }, - { url = "https://files.pythonhosted.org/packages/aa/a4/4290e356cfe028b11db8d96f8d5bca43bde8ed1fd9a491661df4d57551de/fastexcel-0.19.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:26eb85d98087b3c13e083a1fb51a3dfcd57607865fb44d8d6db451948ef65c63", size = 3069723 }, - { url = "https://files.pythonhosted.org/packages/fb/1e/1364b08c1d5449236af23366ac3beaabbc63b283f354fc1aa6ad0b95cc37/fastexcel-0.19.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:42d48b077b7ec070de6ea34c99f9a0c97e45cd767fbadd135fc30fa70de24b42", size = 2852643 }, - { url = "https://files.pythonhosted.org/packages/85/1b/57a5e2441ab29ecb774f642f66d5e9f9246cdc14ca4ee85ada5b081f4656/fastexcel-0.19.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3c49fac330cc306bb0bd73d96138f438441d8254eed19ca6c1800aaa9d69054", size = 3182805 }, - { url = "https://files.pythonhosted.org/packages/76/50/0e5c416b990d153bad1e63b8268ea751fc8a319d134de14e3bbba38000c7/fastexcel-0.19.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75aad96c34836eca90fc6d0e061240c145795f8754424698e2aadfd634abb4cf", size = 3360260 }, - { url = "https://files.pythonhosted.org/packages/48/38/b3faa12a74f387e037ff33adae761c22fc3aa44eed15a2c09d653b4eb194/fastexcel-0.19.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7ef8e41cb0118f90d5f9a636fcdc0e9d635938cdaa54a3182328f3d34ce9ee1a", size = 2897686 }, + { url = "https://files.pythonhosted.org/packages/d1/e0/3820e93ea606549cfddb8c437141dd69f2b245e74785efc8bd7511ba909d/fastexcel-0.19.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:68601072a0b4b4277c165b68f1055f88ef7ffe7ed6f08c1eeda0f0271e3f7da0", size = 3082362, upload-time = "2026-01-20T11:17:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/66/0f/b42dc09515879192919942157292912393584045fd8bad98bd92961d4c30/fastexcel-0.19.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c8a87d94445678e7e3f46a6aa39d2afaee5b88a983ec3661143a6488d8955f44", size = 2864365, upload-time = "2026-01-20T11:17:28.786Z" }, + { url = "https://files.pythonhosted.org/packages/8e/4a/bc358b20fcff64b4c14ff7d7a0e1f797792b8b77e30ae755873c02362538/fastexcel-0.19.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e94fc1be6642555f277af792c22a9f80ec9b4d640d9690f00abb822b6d865069", size = 3186426, upload-time = "2026-01-20T11:17:19.087Z" }, + { url = "https://files.pythonhosted.org/packages/58/ae/d2ffdc5ad14190153e2422fc90a1052a4b0c3086d24cb8ae8967575321d8/fastexcel-0.19.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:334f9f40cd68b5924a712b6c104949757a0b8ad8a7e3fa3f3fad1c1ebc00258b", size = 3365628, upload-time = "2026-01-20T11:17:21.116Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/5f6d4e7760dc3dd8244cd124dabdd5bb7622bf1197edcc2513648847690e/fastexcel-0.19.0-cp310-abi3-win_amd64.whl", hash = "sha256:fbbdf9de79c3ef3572809bb187927c0dc5840968ffe513ea015a383024b7c6b0", size = 2905173, upload-time = "2026-01-20T11:17:33.687Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/4290e356cfe028b11db8d96f8d5bca43bde8ed1fd9a491661df4d57551de/fastexcel-0.19.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:26eb85d98087b3c13e083a1fb51a3dfcd57607865fb44d8d6db451948ef65c63", size = 3069723, upload-time = "2026-01-20T11:17:30.521Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1e/1364b08c1d5449236af23366ac3beaabbc63b283f354fc1aa6ad0b95cc37/fastexcel-0.19.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:42d48b077b7ec070de6ea34c99f9a0c97e45cd767fbadd135fc30fa70de24b42", size = 2852643, upload-time = "2026-01-20T11:17:32.079Z" }, + { url = "https://files.pythonhosted.org/packages/85/1b/57a5e2441ab29ecb774f642f66d5e9f9246cdc14ca4ee85ada5b081f4656/fastexcel-0.19.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3c49fac330cc306bb0bd73d96138f438441d8254eed19ca6c1800aaa9d69054", size = 3182805, upload-time = "2026-01-20T11:17:23.068Z" }, + { url = "https://files.pythonhosted.org/packages/76/50/0e5c416b990d153bad1e63b8268ea751fc8a319d134de14e3bbba38000c7/fastexcel-0.19.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75aad96c34836eca90fc6d0e061240c145795f8754424698e2aadfd634abb4cf", size = 3360260, upload-time = "2026-01-20T11:17:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/48/38/b3faa12a74f387e037ff33adae761c22fc3aa44eed15a2c09d653b4eb194/fastexcel-0.19.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7ef8e41cb0118f90d5f9a636fcdc0e9d635938cdaa54a3182328f3d34ce9ee1a", size = 2897686, upload-time = "2026-01-20T11:17:35.453Z" }, ] [[package]] name = "filelock" version = "3.28.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/17/6e8890271880903e3538660a21d63a6c1fea969ac71d0d6b608b78727fa9/filelock-3.28.0.tar.gz", hash = "sha256:4ed1010aae813c4ee8d9c660e4792475ee60c4a0ba76073ceaf862bd317e3ca6", size = 56474 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/17/6e8890271880903e3538660a21d63a6c1fea969ac71d0d6b608b78727fa9/filelock-3.28.0.tar.gz", hash = "sha256:4ed1010aae813c4ee8d9c660e4792475ee60c4a0ba76073ceaf862bd317e3ca6", size = 56474, upload-time = "2026-04-14T22:54:33.625Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/21/2f728888c45033d34a417bfcd248ea2564c9e08ab1bfd301377cf05d5586/filelock-3.28.0-py3-none-any.whl", hash = "sha256:de9af6712788e7171df1b28b15eba2446c69721433fa427a9bee07b17820a9db", size = 39189 }, + { url = "https://files.pythonhosted.org/packages/3b/21/2f728888c45033d34a417bfcd248ea2564c9e08ab1bfd301377cf05d5586/filelock-3.28.0-py3-none-any.whl", hash = "sha256:de9af6712788e7171df1b28b15eba2446c69721433fa427a9bee07b17820a9db", size = 39189, upload-time = "2026-04-14T22:54:32.037Z" }, ] [[package]] @@ -947,9 +947,9 @@ dependencies = [ { name = "markupsafe" }, { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004 } +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424 }, + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] [[package]] @@ -960,9 +960,9 @@ dependencies = [ { name = "cachelib" }, { name = "flask" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/80/74846c8af58ed60972d64f23a6cd0c3ac0175677d7555dff9f51bf82c294/flask_caching-2.3.1.tar.gz", hash = "sha256:65d7fd1b4eebf810f844de7de6258254b3248296ee429bdcb3f741bcbf7b98c9", size = 67560 } +sdist = { url = "https://files.pythonhosted.org/packages/e2/80/74846c8af58ed60972d64f23a6cd0c3ac0175677d7555dff9f51bf82c294/flask_caching-2.3.1.tar.gz", hash = "sha256:65d7fd1b4eebf810f844de7de6258254b3248296ee429bdcb3f741bcbf7b98c9", size = 67560, upload-time = "2025-02-23T01:34:40.207Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/bb/82daa5e2fcecafadcc8659ce5779679d0641666f9252a4d5a2ae987b0506/Flask_Caching-2.3.1-py3-none-any.whl", hash = "sha256:d3efcf600e5925ea5a2fcb810f13b341ae984f5b52c00e9d9070392f3ca10761", size = 28916 }, + { url = "https://files.pythonhosted.org/packages/00/bb/82daa5e2fcecafadcc8659ce5779679d0641666f9252a4d5a2ae987b0506/Flask_Caching-2.3.1-py3-none-any.whl", hash = "sha256:d3efcf600e5925ea5a2fcb810f13b341ae984f5b52c00e9d9070392f3ca10761", size = 28916, upload-time = "2025-02-23T01:34:37.749Z" }, ] [[package]] @@ -975,9 +975,9 @@ dependencies = [ { name = "brotlicffi", marker = "platform_python_implementation == 'PyPy'" }, { name = "flask" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/de/2ae0118051b38ab53437328074a696f3ee7d61e15bf7454b78a3088e5bc3/flask_compress-1.24.tar.gz", hash = "sha256:14097cefe59ecb3e466d52a6aeb62f34f125a9f7dadf1f33a53e430ce4a50f31", size = 21089 } +sdist = { url = "https://files.pythonhosted.org/packages/c2/de/2ae0118051b38ab53437328074a696f3ee7d61e15bf7454b78a3088e5bc3/flask_compress-1.24.tar.gz", hash = "sha256:14097cefe59ecb3e466d52a6aeb62f34f125a9f7dadf1f33a53e430ce4a50f31", size = 21089, upload-time = "2026-03-31T15:01:39.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/0f/fe51e0b2301bbd429af44273a923ff92127b18d13abba5ae5a1d60e8e497/flask_compress-1.24-py3-none-any.whl", hash = "sha256:1e63668eb6e3242bd4f6ad98825a924e3984409be90c125477893d586007d00c", size = 11033 }, + { url = "https://files.pythonhosted.org/packages/4c/0f/fe51e0b2301bbd429af44273a923ff92127b18d13abba5ae5a1d60e8e497/flask_compress-1.24-py3-none-any.whl", hash = "sha256:1e63668eb6e3242bd4f6ad98825a924e3984409be90c125477893d586007d00c", size = 11033, upload-time = "2026-03-31T15:01:37.302Z" }, ] [[package]] @@ -988,9 +988,9 @@ dependencies = [ { name = "flask" }, { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472 } +sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472, upload-time = "2025-12-12T20:31:42.861Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257 }, + { url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" }, ] [[package]] @@ -1002,9 +1002,9 @@ dependencies = [ { name = "protobuf", marker = "python_full_version >= '3.11'" }, { name = "six", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/d4/538d7d6c6524022bf91ed35a411913cef0e00dcb70fd3b9d3776be631f6e/geobuf-2.0.1.tar.gz", hash = "sha256:73f173e42e50ca546ad81be8ac33511bf09d0fcb58799fa50ef0ee5f01c77561", size = 46593 } +sdist = { url = "https://files.pythonhosted.org/packages/fd/d4/538d7d6c6524022bf91ed35a411913cef0e00dcb70fd3b9d3776be631f6e/geobuf-2.0.1.tar.gz", hash = "sha256:73f173e42e50ca546ad81be8ac33511bf09d0fcb58799fa50ef0ee5f01c77561", size = 46593, upload-time = "2026-01-20T02:05:35.046Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/d6/d02c66ce9dc888f663b127062f43cbdbaa5f4a37dfeaa6937aa5fe67a963/geobuf-2.0.1-py3-none-any.whl", hash = "sha256:b8407af71134aa1e2e55e2ae1d2d241b2c2345e2904de7cca48eb441ee6f2e5d", size = 9773 }, + { url = "https://files.pythonhosted.org/packages/41/d6/d02c66ce9dc888f663b127062f43cbdbaa5f4a37dfeaa6937aa5fe67a963/geobuf-2.0.1-py3-none-any.whl", hash = "sha256:b8407af71134aa1e2e55e2ae1d2d241b2c2345e2904de7cca48eb441ee6f2e5d", size = 9773, upload-time = "2026-01-20T02:05:34.262Z" }, ] [[package]] @@ -1014,18 +1014,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883 } +sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403 }, + { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] @@ -1036,9 +1036,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] @@ -1051,27 +1051,27 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "identify" version = "2.6.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580 } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394 }, + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, ] [[package]] name = "idna" version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] @@ -1081,27 +1081,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405 } +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789 }, + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, ] [[package]] name = "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 } +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 }, + { 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 = "itsdangerous" version = "2.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] [[package]] @@ -1111,9 +1111,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] @@ -1124,212 +1124,212 @@ dependencies = [ { name = "editorconfig" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257 } +sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257, upload-time = "2025-02-27T17:53:53.252Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707 }, + { url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707, upload-time = "2025-02-27T17:53:46.152Z" }, ] [[package]] name = "lxml" version = "6.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/08/1217ca4043f55c3c92993b283a7dbfa456a2058d8b57bbb416cc96b6efff/lxml-6.0.4.tar.gz", hash = "sha256:4137516be2a90775f99d8ef80ec0283f8d78b5d8bd4630ff20163b72e7e9abf2", size = 4237780 } +sdist = { url = "https://files.pythonhosted.org/packages/ce/08/1217ca4043f55c3c92993b283a7dbfa456a2058d8b57bbb416cc96b6efff/lxml-6.0.4.tar.gz", hash = "sha256:4137516be2a90775f99d8ef80ec0283f8d78b5d8bd4630ff20163b72e7e9abf2", size = 4237780, upload-time = "2026-04-12T16:28:24.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/b9/93d71026bf6c4dfe3afc32064a3fcd533d9032c8b97499744a999f97c230/lxml-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4a2c26422c359e93d97afd29f18670ae2079dbe2dd17469f1e181aa6699e96a7", size = 8540588 }, - { url = "https://files.pythonhosted.org/packages/c0/61/33639497c73383e2f53f0b93d485248b77d5498f3589534952bd94380ff3/lxml-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e3b455459e5ed424a4cc277cd085fc1a50a05b940af30703a13a8ec0932d6a69", size = 4601730 }, - { url = "https://files.pythonhosted.org/packages/10/ad/cb2de3d32a0d4748be7cd002a3e3eb67e82027af3796f9fe2462aadb1f7c/lxml-6.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3109bdeb9674abbc4d8bd3fd273cce4a4087a93f31c17dc321130b71384992e5", size = 5000607 }, - { url = "https://files.pythonhosted.org/packages/93/4d/87d8eaba7638c917b2fd971efd1bd93d0662dade95e1d868c18ba7bb84d9/lxml-6.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d41f733476eecf7a919a1b909b12e67f247564b21c2b5d13e5f17851340847da", size = 5154439 }, - { url = "https://files.pythonhosted.org/packages/1b/6a/dd74a938ff10daadbc441bb4bc9d23fb742341da46f2730d7e335cb034bb/lxml-6.0.4-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:717e702b07b512aca0f09d402896e476cfdc1db12bca0441210b1a36fdddb6dd", size = 5055024 }, - { url = "https://files.pythonhosted.org/packages/ef/4a/ac0f195f52fae450338cae90234588a2ead2337440b4e5ff7230775477a3/lxml-6.0.4-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ad61a5fb291e45bb1d680b4de0c99e28547bd249ec57d60e3e59ebe6628a01f", size = 5285427 }, - { url = "https://files.pythonhosted.org/packages/34/f1/804925a5723b911507d7671ab164b697f2e3acb12c0bb17a201569ab848e/lxml-6.0.4-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:2c75422b742dd70cc2b5dbffb181ac093a847b338c7ca1495d92918ae35eabae", size = 5410657 }, - { url = "https://files.pythonhosted.org/packages/73/bc/1d032759c6fbd45c72c29880df44bd2115cdd4574b01a10c9d448496cb75/lxml-6.0.4-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:28df3bd54561a353ce24e80c556e993b397a41a6671d567b6c9bee757e1bf894", size = 4769048 }, - { url = "https://files.pythonhosted.org/packages/b1/d0/a6b5054a2df979d6c348173bc027cb9abaa781fe96590f93a0765f50748c/lxml-6.0.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d7db1fa5f95a8e4fcf0462809f70e536c3248944ddeba692363177ac6b44f2b", size = 5358493 }, - { url = "https://files.pythonhosted.org/packages/c7/ce/99e7233391290b6e9a7d8429846b340aa547f16ad026307bf2a02919a3e2/lxml-6.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8fdae368cb2deb4b2476f886c107aecaaea084e97c0bc0a268861aa0dd2b7237", size = 5106775 }, - { url = "https://files.pythonhosted.org/packages/6f/c8/1d6d65736cec2cd3198bbe512ec121625a3dc4bb7c9dbd19cc0ea967e9b1/lxml-6.0.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:14e4af403766322522440863ca55a9561683b4aedf828df6726b8f83de14a17f", size = 4802389 }, - { url = "https://files.pythonhosted.org/packages/e1/99/2b9b704843f5661347ba33150918d4c1d18025449489b05895d352501ae7/lxml-6.0.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c4633c39204e97f36d68deff76471a0251afe8a82562034e4eda63673ee62d36", size = 5348648 }, - { url = "https://files.pythonhosted.org/packages/3e/af/2f15de7f947a71ee1b4c850d8f1764adfdfae459e434caf50e6c81983da4/lxml-6.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a72e2e31dbc3c35427486402472ca5d8ca2ef2b33648ed0d1b22de2a96347b76", size = 5307603 }, - { url = "https://files.pythonhosted.org/packages/b2/9a/028f3c7981411b90afce0743a12f947a047e7b75a0e0efd3774a704eb49a/lxml-6.0.4-cp310-cp310-win32.whl", hash = "sha256:15f135577ffb6514b40f02c00c1ba0ca6305248b1e310101ca17787beaf4e7ad", size = 3597402 }, - { url = "https://files.pythonhosted.org/packages/32/84/dac34d557eab04384914a9788caf6ec99132434a52a534bf7b367cf8b366/lxml-6.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:fd7f6158824b8bc1e96ae87fb14159553be8f7fa82aec73e0bdf98a5af54290c", size = 4019839 }, - { url = "https://files.pythonhosted.org/packages/97/cb/c91537a07a23ee6c55cf701df3dc34f76cf0daec214adffda9c8395648ef/lxml-6.0.4-cp310-cp310-win_arm64.whl", hash = "sha256:5ff4d73736c80cb9470c8efa492887e4e752a67b7fd798127794e2be103ebef1", size = 3667037 }, - { url = "https://files.pythonhosted.org/packages/15/93/5145f2c9210bf99c01f2f54d364be805f556f2cb13af21d3c2d80e0780bb/lxml-6.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3602d57fdb6f744f4c5d0bd49513fe5abbced08af85bba345fc354336667cd47", size = 8525003 }, - { url = "https://files.pythonhosted.org/packages/93/19/9d61560a53ac1b26aec1a83ae51fadbe0cc0b6534e2c753ad5af854f231b/lxml-6.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8c7976c384dcab4bca42f371449fb711e20f1bfce99c135c9b25614aed80e55", size = 4594697 }, - { url = "https://files.pythonhosted.org/packages/93/1a/0db40884f959c94ede238507ea0967dd47527ab11d130c5a571088637e78/lxml-6.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:579e20c120c3d231e53f0376058e4e1926b71ca4f7b77a7a75f82aea7a9b501e", size = 4922365 }, - { url = "https://files.pythonhosted.org/packages/04/db/4136fab3201087bd5a4db433b9a36e50808d8af759045e7d7af757b46178/lxml-6.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f32a27be5fb286febd16c0d13d4a3aee474d34417bd172e64d76c6a28e2dc14", size = 5066748 }, - { url = "https://files.pythonhosted.org/packages/03/d9/aad543afc57e6268200332ebe695be0320fdd2219b175d34a52027aa1bad/lxml-6.0.4-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d53b7cdaa961a4343312964f6c5a150d075a55e95e1338078d413bf38eba8c0", size = 5000464 }, - { url = "https://files.pythonhosted.org/packages/ab/92/14cc575b97dedf02eb8de96af8d977f06b9f2500213805165606ff06c011/lxml-6.0.4-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d4cc697347f6c61764b58767109e270d0b4a92aba4a8053a967ed9de23a5ea9", size = 5201395 }, - { url = "https://files.pythonhosted.org/packages/a7/72/0ff17f32a737a9c2840f781aee4bbd5cec947b966ff0c74c5dec56098beb/lxml-6.0.4-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:108b8d6da624133eaa1a6a5bbcb1f116b878ea9fd050a1724792d979251706fb", size = 5329108 }, - { url = "https://files.pythonhosted.org/packages/f7/f7/3b1f43e0db54462b5f1ebd96ee43b240388e3b9bf372546694175bec2d41/lxml-6.0.4-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c087d643746489df06fe3ac03460d235b4b3ae705e25838257510c79f834e50f", size = 4658132 }, - { url = "https://files.pythonhosted.org/packages/94/cb/90513445e4f08c500f953543aadf18501e5438b31bc816d0ce9a5e09cc5c/lxml-6.0.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2063c486f80c32a576112201c93269a09ebeca5b663092112c5fb39b32556340", size = 5264665 }, - { url = "https://files.pythonhosted.org/packages/17/d2/c1fa939ea0fa75190dd452d9246f97c16372e2d593fe9f4684cae5c37dda/lxml-6.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ff016e86ec14ae96253a3834302e0e89981956b73e4e74617eeba4a6a81da08b", size = 5043801 }, - { url = "https://files.pythonhosted.org/packages/22/d4/01cdd3c367045526a376cc1eadacf647f193630db3f902b8842a76b3eb2e/lxml-6.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0e9ba5bcd75efb8cb4613463e6cfb55b5a76d4143e4cfa06ea027bc6cc696a3e", size = 4711416 }, - { url = "https://files.pythonhosted.org/packages/8d/77/f6af805c6e23b9a12970c8c38891b087ffd884c2d4df6069e63ff1623fd6/lxml-6.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9a69668bef9268f54a92f2254917df530ca4630a621027437f0e948eb1937e7b", size = 5251326 }, - { url = "https://files.pythonhosted.org/packages/2b/bb/bcd429655f6d12845d91f17e3977d63de22cde5fa77f7d4eef7669a80e8c/lxml-6.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280f8e7398bdc48c7366ad375a5586692cd73b269d9e82e6898f9ada70dc0bcb", size = 5224752 }, - { url = "https://files.pythonhosted.org/packages/69/cd/0342c5a3663115560899a0529789969a72bc5209c8f0084e5b0598cda94d/lxml-6.0.4-cp311-cp311-win32.whl", hash = "sha256:a8eddf3c705e00738db695a9a77830f8d57f7d21a54954fbef23a1b8806384ed", size = 3592977 }, - { url = "https://files.pythonhosted.org/packages/92/c1/386ee2e8a8008cccc4903435f19aaffd16d9286186106752d08be2bd7ccb/lxml-6.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:b74d5b391fc49fc3cc213c930f87a7dedf2b4b0755aae4638e91e4501e278430", size = 4023718 }, - { url = "https://files.pythonhosted.org/packages/a7/a0/19f5072fdc7c73d44004506172dba4b7e3d179d9b3a387efce9c30365afd/lxml-6.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:2f0cf04bafc14b0eebfbc3b5b73b296dd76b5d7640d098c02e75884bb0a70f2b", size = 3666955 }, - { url = "https://files.pythonhosted.org/packages/3d/18/4732abab49bbb041b1ded9dd913ca89735a0dcca038eacec64c44ba02163/lxml-6.0.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af0b8459c4e21a8417db967b2e453d1855022dac79c79b61fb8214f3da50f17e", size = 8570033 }, - { url = "https://files.pythonhosted.org/packages/72/7e/38523ec7178ca35376551911455d1b2766bc9d98bcc18f606a167fa9ecbb/lxml-6.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e0cdcea2affa53fa17dc4bf5cefc0edf72583eac987d669493a019998a623fa3", size = 4623270 }, - { url = "https://files.pythonhosted.org/packages/f1/cf/f9b6c9bf9d8c63d923ef893915141767cea4cea71774f20c36d0c14e1585/lxml-6.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8da4d4840c1bc07da6fcd647784f7fbaf538eeb7a57ce6b2487acc54c5e33330", size = 4929471 }, - { url = "https://files.pythonhosted.org/packages/e5/53/3117f988c9e20be4156d2b8e1bda82ae06878d11aeb820dea111a7cfa4e3/lxml-6.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb04a997588c3980894ded9172c10c5a3e45d3f1c5410472733626d268683806", size = 5092355 }, - { url = "https://files.pythonhosted.org/packages/4e/ca/05c6ac773a2bd3edb48fa8a5c5101e927ce044c4a8aed1a85ff00fab20a5/lxml-6.0.4-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca449642a08a6ceddf6e6775b874b6aee1b6242ed80aea84124497aba28e5384", size = 5004520 }, - { url = "https://files.pythonhosted.org/packages/f1/db/d8aa5aa3a51d0aa6706ef85f85027f7c972cd840fe69ba058ecaf32d093d/lxml-6.0.4-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35b3ccdd137e62033662787dd4d2b8be900c686325d6b91e3b1ff6213d05ba11", size = 5629961 }, - { url = "https://files.pythonhosted.org/packages/9d/75/8fff4444e0493aeb15ab0f4a55c767b5baed9074cf67a1835dc1161f3a1f/lxml-6.0.4-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:45dc690c54b1341fec01743caed02e5f1ea49d7cfb81e3ba48903e5e844ed68a", size = 5237561 }, - { url = "https://files.pythonhosted.org/packages/2a/9f/6d6cd73014f2dbf47a8aa7accd9712726f46ef4891e1c126bc285cfb94e4/lxml-6.0.4-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:15ae922e8f74b05798a0e88cee46c0244aaec6a66b5e00be7d18648fed8c432e", size = 5349197 }, - { url = "https://files.pythonhosted.org/packages/2d/43/e3e9a126e166234d1659d1dd9004dc1dd50cdc3c68575b071b0a1524b4de/lxml-6.0.4-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ebd816653707fbf10c65e3dee3bc24dac6b691654c21533b1ae49287433f4db0", size = 4693123 }, - { url = "https://files.pythonhosted.org/packages/6c/98/b146dd123a4a7b69b571ff23ea8e8c68de8d8c1b03e23d01c6374d4fd835/lxml-6.0.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21284cf36b95dd8be774eb06c304b440cf49ee811800a30080ce6d93700f0383", size = 5242967 }, - { url = "https://files.pythonhosted.org/packages/7e/60/8c275584452b55a902c883e8ab63d755c5ef35d7ad1f06f9e6559095521d/lxml-6.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c08a2a9d0c4028ef5fc5a513b2e1e51af069a83c5b4206139edd08b3b8c2926", size = 5046810 }, - { url = "https://files.pythonhosted.org/packages/19/aa/19ec216147e1105e5403fe73657c693a6e91bde855a13242dd6031e829e5/lxml-6.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1bc2f0f417112cf1a428599dd58125ab74d8e1c66893efd9b907cbb4a5db6e44", size = 4776383 }, - { url = "https://files.pythonhosted.org/packages/41/c8/90afdb838705a736268fcffd2698c05e9a129144ce215d5e14db3bdfc295/lxml-6.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c0d86e328405529bc93913add9ff377e8b8ea9be878e611f19dbac7766a84483", size = 5643497 }, - { url = "https://files.pythonhosted.org/packages/32/ec/1135261ec9822dafb90be0ff6fb0ec79cee0b7fe878833dfe5f2b8c393bd/lxml-6.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3cce9420fe8f91eae5d457582599d282195c958cb670aa4bea313a79103ba33f", size = 5232185 }, - { url = "https://files.pythonhosted.org/packages/13/f2/7380b11cae6943720f525e5a28ad9dbead96ac710417e556b7c03f3a8af3/lxml-6.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96214985ec194ce97b9028414e179cfb21230cba4e2413aee7e249461bb84f4d", size = 5259968 }, - { url = "https://files.pythonhosted.org/packages/65/8f/141734f2c456f2253fed4237d8d4b241e3d701129cf6f0b135ccf241a75a/lxml-6.0.4-cp312-cp312-win32.whl", hash = "sha256:b2209b310e7ed1d4cd1c00d405ec9c49722fce731c7036abc1d876bf8df78139", size = 3594958 }, - { url = "https://files.pythonhosted.org/packages/b7/a9/c6d3531c6d8814af0919fbdb9bda43c9e8b5deffcb70c8534017db233512/lxml-6.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:03affcacfba4671ebc305813b02bfaf34d80b6a7c5b23eafc5d6da14a1a6e623", size = 3995897 }, - { url = "https://files.pythonhosted.org/packages/03/5d/1dabeddf762e5a315a31775b2bca39811d7e7a15fc3e677d044b9da973fe/lxml-6.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:af9678e3a2a047465515d95a61690109af7a4c9486f708249119adcef7861049", size = 3658607 }, - { url = "https://files.pythonhosted.org/packages/78/f6/550a1ed9afde66e24bfcf9892446ea9779152df336062c6df0f7733151a2/lxml-6.0.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecc3d55ed756ee6c3447748862a97e1f5392d2c5d7f474bace9382345e4fc274", size = 8559522 }, - { url = "https://files.pythonhosted.org/packages/11/93/3f687c14d2b4d24b60fe13fd5482c8853f82a10bb87f2b577123e342ed1a/lxml-6.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7d5a627a368a0e861350ccc567a70ec675d2bc4d8b3b54f48995ae78d8d530e", size = 4617380 }, - { url = "https://files.pythonhosted.org/packages/b5/ed/91e443366063d3fb7640ae2badd5d7b65be4095ac6d849788e39c043baae/lxml-6.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d385141b186cc39ebe4863c1e41936282c65df19b2d06a701dedc2a898877d6a", size = 4922791 }, - { url = "https://files.pythonhosted.org/packages/30/4b/2243260b70974aca9ba0cc71bd668c0c3a79644d80ddcabbfbdb4b131848/lxml-6.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0132bb040e9bb5a199302e12bf942741defbc52922a2a06ce9ff7be0d0046483", size = 5080972 }, - { url = "https://files.pythonhosted.org/packages/f8/c3/54c53c4f772341bc12331557f8b0882a426f53133926306cbe6d7f0ee7e4/lxml-6.0.4-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26aee5321e4aa1f07c9090a35f6ab8b703903fb415c6c823cfdb20ee0d779855", size = 4992236 }, - { url = "https://files.pythonhosted.org/packages/be/0f/416de42e22f287585abee610eb0d1c2638c9fe24cee7e15136e0b5e138f8/lxml-6.0.4-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5652455de198ff76e02cfa57d5efc5f834fa45521aaf3fcc13d6b5a88bde23d", size = 5612398 }, - { url = "https://files.pythonhosted.org/packages/7d/63/29a3fa79b8a182f5bd5b5bdcb6f625f49f08f41d60a26ca25482820a1b99/lxml-6.0.4-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75842801fb48aea73f4c281b923a010dfb39bad75edf8ceb2198ec30c27f01cc", size = 5227480 }, - { url = "https://files.pythonhosted.org/packages/7c/4a/44d1843de599b1c6dbe578e4248c2f15e7fac90c5c86eb26775eaeac0fe0/lxml-6.0.4-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:94a1f74607a5a049ff6ff8de429fec922e643e32b5b08ec7a4fe49e8de76e17c", size = 5341001 }, - { url = "https://files.pythonhosted.org/packages/0d/52/c8aebde49f169e4e3452e7756be35be1cb2903e30d961cb57aa65a27055f/lxml-6.0.4-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:173cc246d3d3b6d3b6491f0b3aaf22ebdf2eed616879482acad8bd84d73eb231", size = 4699105 }, - { url = "https://files.pythonhosted.org/packages/78/60/76fc3735c31c28b70220d99452fb72052e84b618693ca2524da96f0131d8/lxml-6.0.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f0f2ee1be1b72e9890da87e4e422f2f703ff4638fd5ec5383055db431e8e30e9", size = 5231095 }, - { url = "https://files.pythonhosted.org/packages/e5/60/448f01c52110102f23df5f07b3f4fde57c8e13e497e182a743d125324c0b/lxml-6.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c51a274b7e8b9ce394c3f8b471eb0b23c1914eec64fdccf674e082daf72abf11", size = 5042411 }, - { url = "https://files.pythonhosted.org/packages/4a/2a/90612a001fa4fa0ff0443ebb0256a542670fe35473734c559720293e7aff/lxml-6.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:210ea934cba1a1ec42f88c4190c4d5c67b2d14321a8faed9b39e8378198ff99d", size = 4768431 }, - { url = "https://files.pythonhosted.org/packages/84/d8/572845a7d741c8a8ffeaf928185263e14d97fbd355de164677340951d7a5/lxml-6.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:14fe654a59eebe16368c51778caeb0c8fda6f897adcd9afe828d87d13b5d5e51", size = 5634972 }, - { url = "https://files.pythonhosted.org/packages/d7/1d/392b8c9f8cf1d502bbec50dee137c7af3dd5def5e5cd84572fbf0ba0541c/lxml-6.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ec160a2b7e2b3cb71ec35010b19a1adea05785d19ba5c9c5f986b64b78fef564", size = 5222909 }, - { url = "https://files.pythonhosted.org/packages/21/ab/949fc96f825cf083612aee65d5a02eacc5eaeb2815561220e33e1e160677/lxml-6.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d305b86ef10b23cf3a6d62a2ad23fa296f76495183ee623f64d2600f65ffe09c", size = 5249096 }, - { url = "https://files.pythonhosted.org/packages/56/e8/fbe44df79ede5ff760401cc3c49c4204f49f0f529cc6b27d0af7b63f5472/lxml-6.0.4-cp313-cp313-win32.whl", hash = "sha256:a2f31380aa9a9b52591e79f1c1d3ac907688fbeb9d883ba28be70f2eb5db2277", size = 3595808 }, - { url = "https://files.pythonhosted.org/packages/f8/df/e873abb881092256520edf0d67d686e36f3c86b3cf289f01b6458272dede/lxml-6.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:b8efa9f681f15043e497293d58a4a63199564b253ed2291887d92bb3f74f59ab", size = 3994635 }, - { url = "https://files.pythonhosted.org/packages/23/a8/9c56c8914b9b18d89face5a7472445002baf309167f7af65d988842129fd/lxml-6.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:905abe6a5888129be18f85f2aea51f0c9863fa0722fb8530dfbb687d2841d221", size = 3657374 }, - { url = "https://files.pythonhosted.org/packages/10/18/36e28a809c509a67496202771f545219ac5a2f1cd61aae325991fcf5ab91/lxml-6.0.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:569d3b18340863f603582d2124e742a68e85755eff5e47c26a55e298521e3a01", size = 8575045 }, - { url = "https://files.pythonhosted.org/packages/11/38/a168c820e3b08d3b4fa0f4e6b53b3930086b36cc11e428106d38c36778cd/lxml-6.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3b6245ee5241342d45e1a54a4a8bc52ef322333ada74f24aa335c4ab36f20161", size = 4622963 }, - { url = "https://files.pythonhosted.org/packages/53/e0/2c9d6abdd82358cea3c0d8d6ca272a6af0f38156abce7827efb6d5b62d17/lxml-6.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79a1173ba3213a3693889a435417d4e9f3c07d96e30dc7cc3a712ed7361015fe", size = 4948832 }, - { url = "https://files.pythonhosted.org/packages/96/d7/f2202852e91d7baf3a317f4523a9c14834145301e5b0f2e80c01c4bfbd49/lxml-6.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc18bb975666b443ba23aedd2fcf57e9d0d97546b52a1de97a447c4061ba4110", size = 5085865 }, - { url = "https://files.pythonhosted.org/packages/09/57/abee549324496e92708f71391c6060a164d3c95369656a1a15e9f20d8162/lxml-6.0.4-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2079f5dc83291ac190a52f8354b78648f221ecac19fb2972a2d056b555824de7", size = 5030001 }, - { url = "https://files.pythonhosted.org/packages/c2/f8/432da7178c5917a16468af6c5da68fef7cf3357d4bd0e6f50272ec9a59b5/lxml-6.0.4-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3eda02da4ca16e9ca22bbe5654470c17fa1abcd967a52e4c2e50ff278221e351", size = 5646303 }, - { url = "https://files.pythonhosted.org/packages/82/f9/e1c04ef667a6bf9c9dbd3bf04c50fa51d7ee25b258485bb748b27eb9a1c7/lxml-6.0.4-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3787cdc3832b70e21ac2efafea2a82a8ccb5e85bec110dc68b26023e9d3caae", size = 5237940 }, - { url = "https://files.pythonhosted.org/packages/d0/f0/cdea60d92df731725fc3c4f33e387b100f210acd45c92969e42d2ba993fa/lxml-6.0.4-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:3f276d49c23103565d39440b9b3f4fc08fa22f5a96395ea4b4d4fea4458b1505", size = 5350050 }, - { url = "https://files.pythonhosted.org/packages/2e/15/bf52c7a70b6081bb9e00d37cc90fcf60aa84468d9d173ad2fade38ec34c5/lxml-6.0.4-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:fdfdad73736402375b11b3a137e48cd09634177516baf5fc0bd80d1ca85f3cda", size = 4696409 }, - { url = "https://files.pythonhosted.org/packages/c5/69/9bade267332cc06f9a9aa773b5a11bdfb249af485df9e142993009ea1fc4/lxml-6.0.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75912421456946931daba0ec3cedfa824c756585d05bde97813a17992bfbd013", size = 5249072 }, - { url = "https://files.pythonhosted.org/packages/14/ca/043bcacb096d6ed291cbbc58724e9625a453069d6edeb840b0bf18038d05/lxml-6.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:48cd5a88da67233fd82f2920db344503c2818255217cd6ea462c9bb8254ba7cb", size = 5083779 }, - { url = "https://files.pythonhosted.org/packages/04/89/f5fb18d76985969e84af13682e489acabee399bb54738a363925ea6e7390/lxml-6.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:87af86a8fa55b9ff1e6ee4233d762296f2ce641ba948af783fb995c5a8a3371b", size = 4736953 }, - { url = "https://files.pythonhosted.org/packages/84/ba/d1d7284bb4ba951f188c3fc0455943c1fcbd1c33d1324d6d57b7d4a45be6/lxml-6.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a743714cd656ba7ccb29d199783906064c7b5ba3c0e2a79f0244ea0badc6a98c", size = 5669605 }, - { url = "https://files.pythonhosted.org/packages/72/05/1463e55f2de27bb60feddc894dd7c0833bd501f8861392ed416291b38db5/lxml-6.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e31c76bd066fb4f81d9a32e5843bffdf939ab27afb1ffc1c924e749bfbdb00e3", size = 5236886 }, - { url = "https://files.pythonhosted.org/packages/fe/fb/0b6ee9194ce3ac49db4cadaa8a9158f04779fc768b6c27c4e2945d71a99d/lxml-6.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f185fd6e7d550e9917d7103dccf51be589aba953e15994fb04646c1730019685", size = 5263382 }, - { url = "https://files.pythonhosted.org/packages/9a/93/ec18a08e98dd82cac39f1d2511ee2bed5affb94d228356d8ef165a4ec3b9/lxml-6.0.4-cp314-cp314-win32.whl", hash = "sha256:774660028f8722a598400430d2746fb0075949f84a9a5cd9767d9152e3baaac5", size = 3656164 }, - { url = "https://files.pythonhosted.org/packages/15/86/52507316abfc7150bf6bb191e39a12e301ee80334610a493884ae2f9d20d/lxml-6.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:fbd7d14349413f5609c0b537b1a48117d6ccef1af37986af6b03766ad05bf43e", size = 4062512 }, - { url = "https://files.pythonhosted.org/packages/f1/d5/09c593a2ef2234b8cd6cf059e2dc212e0654bf05c503f0ef2daf05adb680/lxml-6.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:a61a01ec3fbfd5b73a69a7bf513271051fd6c5795d82fc5daa0255934cd8db3d", size = 3740745 }, - { url = "https://files.pythonhosted.org/packages/4a/3c/42a98bf6693938bf7b285ec7f70ba2ae9d785d0e5b2cdb85d2ee29e287eb/lxml-6.0.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:504edb62df33cea502ea6e73847c647ba228623ca3f80a228be5723a70984dd5", size = 8826437 }, - { url = "https://files.pythonhosted.org/packages/c2/c2/ad13f39b2db8709788aa2dcb6e90b81da76db3b5b2e7d35e0946cf984960/lxml-6.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f01b7b0316d4c0926d49a7f003b2d30539f392b140a3374bb788bad180bc8478", size = 4734892 }, - { url = "https://files.pythonhosted.org/packages/2c/6d/c559d7b5922c5b0380fc2cb5ac134b6a3f9d79d368347a624ee5d68b0816/lxml-6.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab999933e662501efe4b16e6cfb7c9f9deca7d072cd1788b99c8defde78c0dfb", size = 4969173 }, - { url = "https://files.pythonhosted.org/packages/c7/78/ca521e36157f38e3e1a29276855cdf48d213138fc0c8365693ff5c876ca7/lxml-6.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67c3f084389fe75932c39b6869a377f6c8e21e818f31ae8a30c71dd2e59360e2", size = 5103134 }, - { url = "https://files.pythonhosted.org/packages/28/a7/7d62d023bacaa0aaf60af8c0a77c6c05f84327396d755f3aa64b788678a9/lxml-6.0.4-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:377ea1d654f76ed6205c87d14920f829c9f4d31df83374d3cbcbdaae804d37b2", size = 5027205 }, - { url = "https://files.pythonhosted.org/packages/34/be/51b194b81684f2e85e5d992771c45d70cb22ac6f7291ac6bc7b255830afe/lxml-6.0.4-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e60cd0bcacbfd1a96d63516b622183fb2e3f202300df9eb5533391a8a939dbfa", size = 5594461 }, - { url = "https://files.pythonhosted.org/packages/39/24/8850f38fbf89dd072ff31ba22f9e40347aeada7cadf710ecb04b8d9f32d4/lxml-6.0.4-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9e30fd63d41dd0bbdb020af5cdfffd5d9b554d907cb210f18e8fcdc8eac013", size = 5223378 }, - { url = "https://files.pythonhosted.org/packages/2a/9b/595239ba8c719b0fdc7bc9ebdb7564459c9a6b24b8b363df4a02674aeece/lxml-6.0.4-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:1fb4a1606bb68c533002e7ed50d7e55e58f0ef1696330670281cb79d5ab2050d", size = 5311415 }, - { url = "https://files.pythonhosted.org/packages/be/cb/aa27ac8d041acf34691577838494ad08df78e83fdfdb66948d2903e9291e/lxml-6.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:695c7708438e449d57f404db8cc1b769e77ad5b50655f32f8175686ba752f293", size = 4637953 }, - { url = "https://files.pythonhosted.org/packages/f6/f2/f19114fd86825c2d1ce41cd99daad218d30cfdd2093d4de9273986fb4d68/lxml-6.0.4-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d49c35ae1e35ee9b569892cf8f8f88db9524f28d66e9daee547a5ef9f3c5f468", size = 5231532 }, - { url = "https://files.pythonhosted.org/packages/9a/0e/c3fa354039ec0b6b09f40fbe1129efc572ac6239faa4906de42d5ce87c0a/lxml-6.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5801072f8967625e6249d162065d0d6011ef8ce3d0efb8754496b5246b81a74b", size = 5083767 }, - { url = "https://files.pythonhosted.org/packages/b3/4b/1a0dbb6d6ffae16e54a8a3796ded0ad2f9c3bc1ff3728bde33456f4e1d63/lxml-6.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cbf768541526eba5ef1a49f991122e41b39781eafd0445a5a110fc09947a20b5", size = 4758079 }, - { url = "https://files.pythonhosted.org/packages/a9/01/a246cf5f80f96766051de4b305d6552f80bdaefb37f04e019e42af0aba69/lxml-6.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eecce87cc09233786fc31c230268183bf6375126cfec1c8b3673fcdc8767b560", size = 5618686 }, - { url = "https://files.pythonhosted.org/packages/eb/1f/b072a92369039ebef11b0a654be5134fcf3ed04c0f437faf9435ac9ba845/lxml-6.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:07dce892881179e11053066faca2da17b0eeb0bb7298f11bcf842a86db207dbd", size = 5227259 }, - { url = "https://files.pythonhosted.org/packages/d5/a0/dc97034f9d4c0c4d30875147d81fd2c0c7f3d261b109db36ed746bf8ab1d/lxml-6.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e4f97aee337b947e6699e5574c90d087d3e2ce517016241c07e7e98a28dca885", size = 5246190 }, - { url = "https://files.pythonhosted.org/packages/f2/ef/85cb69835113583c2516fee07d0ffb4d824b557424b06ba5872c20ba6078/lxml-6.0.4-cp314-cp314t-win32.whl", hash = "sha256:064477c0d4c695aa1ea4b9c1c4ee9043ab740d12135b74c458cc658350adcd86", size = 3896005 }, - { url = "https://files.pythonhosted.org/packages/3d/5e/2231f34cc54b8422b793593138d86d3fa4588fb2297d4ea0472390f25627/lxml-6.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:25bad2d8438f4ef5a7ad4a8d8bcaadde20c0daced8bdb56d46236b0a7d1cbdd0", size = 4391037 }, - { url = "https://files.pythonhosted.org/packages/39/53/8ba3cd5984f8363635450c93f63e541a0721b362bb32ae0d8237d9674aee/lxml-6.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:1dcd9e6cb9b7df808ea33daebd1801f37a8f50e8c075013ed2a2343246727838", size = 3816184 }, - { url = "https://files.pythonhosted.org/packages/41/25/260b86340ec5aadda5e18ed39df0eea61ef8781fb0fcc16c847cdb9dfdff/lxml-6.0.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b29bcca95e82cd201d16c2101085faa2669838f4697fd914b7124a6c77032f80", size = 3929209 }, - { url = "https://files.pythonhosted.org/packages/8a/cc/b2157461584525fb0ceb7f4c3b6c1b276f6c7dd34858d78075ae8973bf3d/lxml-6.0.4-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a95e29710ecdf99b446990144598f6117271cb2ec19fd45634aa087892087077", size = 4209535 }, - { url = "https://files.pythonhosted.org/packages/1d/fa/7fdcd1eb31ec0d5871a4a0b1587e78a331f59941ff3af59bed064175499e/lxml-6.0.4-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13085e0174e9c9fa4eb5a6bdfb81646d1f7be07e5895c958e89838afb77630c6", size = 4316979 }, - { url = "https://files.pythonhosted.org/packages/53/0c/dab9f5855e7d2e51c8eb461713ada38a7d4eb3ab07fec8d13c46ed353ad6/lxml-6.0.4-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e205c4869a28ec4447375333072978356cd0eeadd0412c643543238e638b89a3", size = 4249929 }, - { url = "https://files.pythonhosted.org/packages/a4/88/39e8e4ca7ee1bc9e7cd2f6b311279624afa70a375eef8727f0bb83db2936/lxml-6.0.4-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aec26080306a66ad5c62fad0053dd2170899b465137caca7eac4b72bda3588bf", size = 4399464 }, - { url = "https://files.pythonhosted.org/packages/66/54/14c518cc9ce5151fcd1fa95a1c2396799a505dca2c4f0acdf85fb23fe293/lxml-6.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3912221f41d96283b10a7232344351c8511e31f18734c752ed4798c12586ea35", size = 3507404 }, + { url = "https://files.pythonhosted.org/packages/c6/b9/93d71026bf6c4dfe3afc32064a3fcd533d9032c8b97499744a999f97c230/lxml-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4a2c26422c359e93d97afd29f18670ae2079dbe2dd17469f1e181aa6699e96a7", size = 8540588, upload-time = "2026-04-12T16:22:56.746Z" }, + { url = "https://files.pythonhosted.org/packages/c0/61/33639497c73383e2f53f0b93d485248b77d5498f3589534952bd94380ff3/lxml-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e3b455459e5ed424a4cc277cd085fc1a50a05b940af30703a13a8ec0932d6a69", size = 4601730, upload-time = "2026-04-12T16:22:59.152Z" }, + { url = "https://files.pythonhosted.org/packages/10/ad/cb2de3d32a0d4748be7cd002a3e3eb67e82027af3796f9fe2462aadb1f7c/lxml-6.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3109bdeb9674abbc4d8bd3fd273cce4a4087a93f31c17dc321130b71384992e5", size = 5000607, upload-time = "2026-04-12T16:23:01.103Z" }, + { url = "https://files.pythonhosted.org/packages/93/4d/87d8eaba7638c917b2fd971efd1bd93d0662dade95e1d868c18ba7bb84d9/lxml-6.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d41f733476eecf7a919a1b909b12e67f247564b21c2b5d13e5f17851340847da", size = 5154439, upload-time = "2026-04-12T16:23:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6a/dd74a938ff10daadbc441bb4bc9d23fb742341da46f2730d7e335cb034bb/lxml-6.0.4-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:717e702b07b512aca0f09d402896e476cfdc1db12bca0441210b1a36fdddb6dd", size = 5055024, upload-time = "2026-04-12T16:23:06.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4a/ac0f195f52fae450338cae90234588a2ead2337440b4e5ff7230775477a3/lxml-6.0.4-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ad61a5fb291e45bb1d680b4de0c99e28547bd249ec57d60e3e59ebe6628a01f", size = 5285427, upload-time = "2026-04-12T16:23:08.081Z" }, + { url = "https://files.pythonhosted.org/packages/34/f1/804925a5723b911507d7671ab164b697f2e3acb12c0bb17a201569ab848e/lxml-6.0.4-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:2c75422b742dd70cc2b5dbffb181ac093a847b338c7ca1495d92918ae35eabae", size = 5410657, upload-time = "2026-04-12T16:23:11.154Z" }, + { url = "https://files.pythonhosted.org/packages/73/bc/1d032759c6fbd45c72c29880df44bd2115cdd4574b01a10c9d448496cb75/lxml-6.0.4-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:28df3bd54561a353ce24e80c556e993b397a41a6671d567b6c9bee757e1bf894", size = 4769048, upload-time = "2026-04-12T16:23:13.306Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/a6b5054a2df979d6c348173bc027cb9abaa781fe96590f93a0765f50748c/lxml-6.0.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d7db1fa5f95a8e4fcf0462809f70e536c3248944ddeba692363177ac6b44f2b", size = 5358493, upload-time = "2026-04-12T16:23:15.927Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ce/99e7233391290b6e9a7d8429846b340aa547f16ad026307bf2a02919a3e2/lxml-6.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8fdae368cb2deb4b2476f886c107aecaaea084e97c0bc0a268861aa0dd2b7237", size = 5106775, upload-time = "2026-04-12T16:23:18.276Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c8/1d6d65736cec2cd3198bbe512ec121625a3dc4bb7c9dbd19cc0ea967e9b1/lxml-6.0.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:14e4af403766322522440863ca55a9561683b4aedf828df6726b8f83de14a17f", size = 4802389, upload-time = "2026-04-12T16:23:20.948Z" }, + { url = "https://files.pythonhosted.org/packages/e1/99/2b9b704843f5661347ba33150918d4c1d18025449489b05895d352501ae7/lxml-6.0.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c4633c39204e97f36d68deff76471a0251afe8a82562034e4eda63673ee62d36", size = 5348648, upload-time = "2026-04-12T16:23:23.18Z" }, + { url = "https://files.pythonhosted.org/packages/3e/af/2f15de7f947a71ee1b4c850d8f1764adfdfae459e434caf50e6c81983da4/lxml-6.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a72e2e31dbc3c35427486402472ca5d8ca2ef2b33648ed0d1b22de2a96347b76", size = 5307603, upload-time = "2026-04-12T16:23:25.169Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/028f3c7981411b90afce0743a12f947a047e7b75a0e0efd3774a704eb49a/lxml-6.0.4-cp310-cp310-win32.whl", hash = "sha256:15f135577ffb6514b40f02c00c1ba0ca6305248b1e310101ca17787beaf4e7ad", size = 3597402, upload-time = "2026-04-12T16:23:27.416Z" }, + { url = "https://files.pythonhosted.org/packages/32/84/dac34d557eab04384914a9788caf6ec99132434a52a534bf7b367cf8b366/lxml-6.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:fd7f6158824b8bc1e96ae87fb14159553be8f7fa82aec73e0bdf98a5af54290c", size = 4019839, upload-time = "2026-04-12T16:23:29.594Z" }, + { url = "https://files.pythonhosted.org/packages/97/cb/c91537a07a23ee6c55cf701df3dc34f76cf0daec214adffda9c8395648ef/lxml-6.0.4-cp310-cp310-win_arm64.whl", hash = "sha256:5ff4d73736c80cb9470c8efa492887e4e752a67b7fd798127794e2be103ebef1", size = 3667037, upload-time = "2026-04-12T16:23:31.768Z" }, + { url = "https://files.pythonhosted.org/packages/15/93/5145f2c9210bf99c01f2f54d364be805f556f2cb13af21d3c2d80e0780bb/lxml-6.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3602d57fdb6f744f4c5d0bd49513fe5abbced08af85bba345fc354336667cd47", size = 8525003, upload-time = "2026-04-12T16:23:34.045Z" }, + { url = "https://files.pythonhosted.org/packages/93/19/9d61560a53ac1b26aec1a83ae51fadbe0cc0b6534e2c753ad5af854f231b/lxml-6.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8c7976c384dcab4bca42f371449fb711e20f1bfce99c135c9b25614aed80e55", size = 4594697, upload-time = "2026-04-12T16:23:36.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/1a/0db40884f959c94ede238507ea0967dd47527ab11d130c5a571088637e78/lxml-6.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:579e20c120c3d231e53f0376058e4e1926b71ca4f7b77a7a75f82aea7a9b501e", size = 4922365, upload-time = "2026-04-12T16:23:38.709Z" }, + { url = "https://files.pythonhosted.org/packages/04/db/4136fab3201087bd5a4db433b9a36e50808d8af759045e7d7af757b46178/lxml-6.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f32a27be5fb286febd16c0d13d4a3aee474d34417bd172e64d76c6a28e2dc14", size = 5066748, upload-time = "2026-04-12T16:23:41.048Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/aad543afc57e6268200332ebe695be0320fdd2219b175d34a52027aa1bad/lxml-6.0.4-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d53b7cdaa961a4343312964f6c5a150d075a55e95e1338078d413bf38eba8c0", size = 5000464, upload-time = "2026-04-12T16:23:42.946Z" }, + { url = "https://files.pythonhosted.org/packages/ab/92/14cc575b97dedf02eb8de96af8d977f06b9f2500213805165606ff06c011/lxml-6.0.4-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d4cc697347f6c61764b58767109e270d0b4a92aba4a8053a967ed9de23a5ea9", size = 5201395, upload-time = "2026-04-12T16:23:45.227Z" }, + { url = "https://files.pythonhosted.org/packages/a7/72/0ff17f32a737a9c2840f781aee4bbd5cec947b966ff0c74c5dec56098beb/lxml-6.0.4-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:108b8d6da624133eaa1a6a5bbcb1f116b878ea9fd050a1724792d979251706fb", size = 5329108, upload-time = "2026-04-12T16:23:48.094Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f7/3b1f43e0db54462b5f1ebd96ee43b240388e3b9bf372546694175bec2d41/lxml-6.0.4-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c087d643746489df06fe3ac03460d235b4b3ae705e25838257510c79f834e50f", size = 4658132, upload-time = "2026-04-12T16:23:50.279Z" }, + { url = "https://files.pythonhosted.org/packages/94/cb/90513445e4f08c500f953543aadf18501e5438b31bc816d0ce9a5e09cc5c/lxml-6.0.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2063c486f80c32a576112201c93269a09ebeca5b663092112c5fb39b32556340", size = 5264665, upload-time = "2026-04-12T16:23:52.397Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/c1fa939ea0fa75190dd452d9246f97c16372e2d593fe9f4684cae5c37dda/lxml-6.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ff016e86ec14ae96253a3834302e0e89981956b73e4e74617eeba4a6a81da08b", size = 5043801, upload-time = "2026-04-12T16:23:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/22/d4/01cdd3c367045526a376cc1eadacf647f193630db3f902b8842a76b3eb2e/lxml-6.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0e9ba5bcd75efb8cb4613463e6cfb55b5a76d4143e4cfa06ea027bc6cc696a3e", size = 4711416, upload-time = "2026-04-12T16:23:57.647Z" }, + { url = "https://files.pythonhosted.org/packages/8d/77/f6af805c6e23b9a12970c8c38891b087ffd884c2d4df6069e63ff1623fd6/lxml-6.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9a69668bef9268f54a92f2254917df530ca4630a621027437f0e948eb1937e7b", size = 5251326, upload-time = "2026-04-12T16:23:59.901Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bb/bcd429655f6d12845d91f17e3977d63de22cde5fa77f7d4eef7669a80e8c/lxml-6.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280f8e7398bdc48c7366ad375a5586692cd73b269d9e82e6898f9ada70dc0bcb", size = 5224752, upload-time = "2026-04-12T16:24:02.002Z" }, + { url = "https://files.pythonhosted.org/packages/69/cd/0342c5a3663115560899a0529789969a72bc5209c8f0084e5b0598cda94d/lxml-6.0.4-cp311-cp311-win32.whl", hash = "sha256:a8eddf3c705e00738db695a9a77830f8d57f7d21a54954fbef23a1b8806384ed", size = 3592977, upload-time = "2026-04-12T16:24:03.847Z" }, + { url = "https://files.pythonhosted.org/packages/92/c1/386ee2e8a8008cccc4903435f19aaffd16d9286186106752d08be2bd7ccb/lxml-6.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:b74d5b391fc49fc3cc213c930f87a7dedf2b4b0755aae4638e91e4501e278430", size = 4023718, upload-time = "2026-04-12T16:24:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a0/19f5072fdc7c73d44004506172dba4b7e3d179d9b3a387efce9c30365afd/lxml-6.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:2f0cf04bafc14b0eebfbc3b5b73b296dd76b5d7640d098c02e75884bb0a70f2b", size = 3666955, upload-time = "2026-04-12T16:24:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/18/4732abab49bbb041b1ded9dd913ca89735a0dcca038eacec64c44ba02163/lxml-6.0.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af0b8459c4e21a8417db967b2e453d1855022dac79c79b61fb8214f3da50f17e", size = 8570033, upload-time = "2026-04-12T16:24:10.728Z" }, + { url = "https://files.pythonhosted.org/packages/72/7e/38523ec7178ca35376551911455d1b2766bc9d98bcc18f606a167fa9ecbb/lxml-6.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e0cdcea2affa53fa17dc4bf5cefc0edf72583eac987d669493a019998a623fa3", size = 4623270, upload-time = "2026-04-12T16:24:13.2Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cf/f9b6c9bf9d8c63d923ef893915141767cea4cea71774f20c36d0c14e1585/lxml-6.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8da4d4840c1bc07da6fcd647784f7fbaf538eeb7a57ce6b2487acc54c5e33330", size = 4929471, upload-time = "2026-04-12T16:24:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/e5/53/3117f988c9e20be4156d2b8e1bda82ae06878d11aeb820dea111a7cfa4e3/lxml-6.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb04a997588c3980894ded9172c10c5a3e45d3f1c5410472733626d268683806", size = 5092355, upload-time = "2026-04-12T16:24:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ca/05c6ac773a2bd3edb48fa8a5c5101e927ce044c4a8aed1a85ff00fab20a5/lxml-6.0.4-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca449642a08a6ceddf6e6775b874b6aee1b6242ed80aea84124497aba28e5384", size = 5004520, upload-time = "2026-04-12T16:24:20.184Z" }, + { url = "https://files.pythonhosted.org/packages/f1/db/d8aa5aa3a51d0aa6706ef85f85027f7c972cd840fe69ba058ecaf32d093d/lxml-6.0.4-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35b3ccdd137e62033662787dd4d2b8be900c686325d6b91e3b1ff6213d05ba11", size = 5629961, upload-time = "2026-04-12T16:24:22.242Z" }, + { url = "https://files.pythonhosted.org/packages/9d/75/8fff4444e0493aeb15ab0f4a55c767b5baed9074cf67a1835dc1161f3a1f/lxml-6.0.4-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:45dc690c54b1341fec01743caed02e5f1ea49d7cfb81e3ba48903e5e844ed68a", size = 5237561, upload-time = "2026-04-12T16:24:24.572Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/6d6cd73014f2dbf47a8aa7accd9712726f46ef4891e1c126bc285cfb94e4/lxml-6.0.4-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:15ae922e8f74b05798a0e88cee46c0244aaec6a66b5e00be7d18648fed8c432e", size = 5349197, upload-time = "2026-04-12T16:24:26.805Z" }, + { url = "https://files.pythonhosted.org/packages/2d/43/e3e9a126e166234d1659d1dd9004dc1dd50cdc3c68575b071b0a1524b4de/lxml-6.0.4-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ebd816653707fbf10c65e3dee3bc24dac6b691654c21533b1ae49287433f4db0", size = 4693123, upload-time = "2026-04-12T16:24:28.812Z" }, + { url = "https://files.pythonhosted.org/packages/6c/98/b146dd123a4a7b69b571ff23ea8e8c68de8d8c1b03e23d01c6374d4fd835/lxml-6.0.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21284cf36b95dd8be774eb06c304b440cf49ee811800a30080ce6d93700f0383", size = 5242967, upload-time = "2026-04-12T16:24:30.811Z" }, + { url = "https://files.pythonhosted.org/packages/7e/60/8c275584452b55a902c883e8ab63d755c5ef35d7ad1f06f9e6559095521d/lxml-6.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c08a2a9d0c4028ef5fc5a513b2e1e51af069a83c5b4206139edd08b3b8c2926", size = 5046810, upload-time = "2026-04-12T16:24:33.289Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/19ec216147e1105e5403fe73657c693a6e91bde855a13242dd6031e829e5/lxml-6.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1bc2f0f417112cf1a428599dd58125ab74d8e1c66893efd9b907cbb4a5db6e44", size = 4776383, upload-time = "2026-04-12T16:24:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/41/c8/90afdb838705a736268fcffd2698c05e9a129144ce215d5e14db3bdfc295/lxml-6.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c0d86e328405529bc93913add9ff377e8b8ea9be878e611f19dbac7766a84483", size = 5643497, upload-time = "2026-04-12T16:24:38.276Z" }, + { url = "https://files.pythonhosted.org/packages/32/ec/1135261ec9822dafb90be0ff6fb0ec79cee0b7fe878833dfe5f2b8c393bd/lxml-6.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3cce9420fe8f91eae5d457582599d282195c958cb670aa4bea313a79103ba33f", size = 5232185, upload-time = "2026-04-12T16:24:40.516Z" }, + { url = "https://files.pythonhosted.org/packages/13/f2/7380b11cae6943720f525e5a28ad9dbead96ac710417e556b7c03f3a8af3/lxml-6.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96214985ec194ce97b9028414e179cfb21230cba4e2413aee7e249461bb84f4d", size = 5259968, upload-time = "2026-04-12T16:24:42.917Z" }, + { url = "https://files.pythonhosted.org/packages/65/8f/141734f2c456f2253fed4237d8d4b241e3d701129cf6f0b135ccf241a75a/lxml-6.0.4-cp312-cp312-win32.whl", hash = "sha256:b2209b310e7ed1d4cd1c00d405ec9c49722fce731c7036abc1d876bf8df78139", size = 3594958, upload-time = "2026-04-12T16:24:45.039Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a9/c6d3531c6d8814af0919fbdb9bda43c9e8b5deffcb70c8534017db233512/lxml-6.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:03affcacfba4671ebc305813b02bfaf34d80b6a7c5b23eafc5d6da14a1a6e623", size = 3995897, upload-time = "2026-04-12T16:24:46.98Z" }, + { url = "https://files.pythonhosted.org/packages/03/5d/1dabeddf762e5a315a31775b2bca39811d7e7a15fc3e677d044b9da973fe/lxml-6.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:af9678e3a2a047465515d95a61690109af7a4c9486f708249119adcef7861049", size = 3658607, upload-time = "2026-04-12T16:24:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/550a1ed9afde66e24bfcf9892446ea9779152df336062c6df0f7733151a2/lxml-6.0.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecc3d55ed756ee6c3447748862a97e1f5392d2c5d7f474bace9382345e4fc274", size = 8559522, upload-time = "2026-04-12T16:24:51.563Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/3f687c14d2b4d24b60fe13fd5482c8853f82a10bb87f2b577123e342ed1a/lxml-6.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7d5a627a368a0e861350ccc567a70ec675d2bc4d8b3b54f48995ae78d8d530e", size = 4617380, upload-time = "2026-04-12T16:24:54.042Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ed/91e443366063d3fb7640ae2badd5d7b65be4095ac6d849788e39c043baae/lxml-6.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d385141b186cc39ebe4863c1e41936282c65df19b2d06a701dedc2a898877d6a", size = 4922791, upload-time = "2026-04-12T16:24:56.381Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/2243260b70974aca9ba0cc71bd668c0c3a79644d80ddcabbfbdb4b131848/lxml-6.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0132bb040e9bb5a199302e12bf942741defbc52922a2a06ce9ff7be0d0046483", size = 5080972, upload-time = "2026-04-12T16:24:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c3/54c53c4f772341bc12331557f8b0882a426f53133926306cbe6d7f0ee7e4/lxml-6.0.4-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26aee5321e4aa1f07c9090a35f6ab8b703903fb415c6c823cfdb20ee0d779855", size = 4992236, upload-time = "2026-04-12T16:25:01.099Z" }, + { url = "https://files.pythonhosted.org/packages/be/0f/416de42e22f287585abee610eb0d1c2638c9fe24cee7e15136e0b5e138f8/lxml-6.0.4-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5652455de198ff76e02cfa57d5efc5f834fa45521aaf3fcc13d6b5a88bde23d", size = 5612398, upload-time = "2026-04-12T16:25:03.517Z" }, + { url = "https://files.pythonhosted.org/packages/7d/63/29a3fa79b8a182f5bd5b5bdcb6f625f49f08f41d60a26ca25482820a1b99/lxml-6.0.4-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75842801fb48aea73f4c281b923a010dfb39bad75edf8ceb2198ec30c27f01cc", size = 5227480, upload-time = "2026-04-12T16:25:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4a/44d1843de599b1c6dbe578e4248c2f15e7fac90c5c86eb26775eaeac0fe0/lxml-6.0.4-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:94a1f74607a5a049ff6ff8de429fec922e643e32b5b08ec7a4fe49e8de76e17c", size = 5341001, upload-time = "2026-04-12T16:25:08.563Z" }, + { url = "https://files.pythonhosted.org/packages/0d/52/c8aebde49f169e4e3452e7756be35be1cb2903e30d961cb57aa65a27055f/lxml-6.0.4-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:173cc246d3d3b6d3b6491f0b3aaf22ebdf2eed616879482acad8bd84d73eb231", size = 4699105, upload-time = "2026-04-12T16:25:10.757Z" }, + { url = "https://files.pythonhosted.org/packages/78/60/76fc3735c31c28b70220d99452fb72052e84b618693ca2524da96f0131d8/lxml-6.0.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f0f2ee1be1b72e9890da87e4e422f2f703ff4638fd5ec5383055db431e8e30e9", size = 5231095, upload-time = "2026-04-12T16:25:13.305Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/448f01c52110102f23df5f07b3f4fde57c8e13e497e182a743d125324c0b/lxml-6.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c51a274b7e8b9ce394c3f8b471eb0b23c1914eec64fdccf674e082daf72abf11", size = 5042411, upload-time = "2026-04-12T16:25:15.541Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2a/90612a001fa4fa0ff0443ebb0256a542670fe35473734c559720293e7aff/lxml-6.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:210ea934cba1a1ec42f88c4190c4d5c67b2d14321a8faed9b39e8378198ff99d", size = 4768431, upload-time = "2026-04-12T16:25:17.581Z" }, + { url = "https://files.pythonhosted.org/packages/84/d8/572845a7d741c8a8ffeaf928185263e14d97fbd355de164677340951d7a5/lxml-6.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:14fe654a59eebe16368c51778caeb0c8fda6f897adcd9afe828d87d13b5d5e51", size = 5634972, upload-time = "2026-04-12T16:25:20.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1d/392b8c9f8cf1d502bbec50dee137c7af3dd5def5e5cd84572fbf0ba0541c/lxml-6.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ec160a2b7e2b3cb71ec35010b19a1adea05785d19ba5c9c5f986b64b78fef564", size = 5222909, upload-time = "2026-04-12T16:25:22.243Z" }, + { url = "https://files.pythonhosted.org/packages/21/ab/949fc96f825cf083612aee65d5a02eacc5eaeb2815561220e33e1e160677/lxml-6.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d305b86ef10b23cf3a6d62a2ad23fa296f76495183ee623f64d2600f65ffe09c", size = 5249096, upload-time = "2026-04-12T16:25:24.781Z" }, + { url = "https://files.pythonhosted.org/packages/56/e8/fbe44df79ede5ff760401cc3c49c4204f49f0f529cc6b27d0af7b63f5472/lxml-6.0.4-cp313-cp313-win32.whl", hash = "sha256:a2f31380aa9a9b52591e79f1c1d3ac907688fbeb9d883ba28be70f2eb5db2277", size = 3595808, upload-time = "2026-04-12T16:25:26.747Z" }, + { url = "https://files.pythonhosted.org/packages/f8/df/e873abb881092256520edf0d67d686e36f3c86b3cf289f01b6458272dede/lxml-6.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:b8efa9f681f15043e497293d58a4a63199564b253ed2291887d92bb3f74f59ab", size = 3994635, upload-time = "2026-04-12T16:25:28.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/9c56c8914b9b18d89face5a7472445002baf309167f7af65d988842129fd/lxml-6.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:905abe6a5888129be18f85f2aea51f0c9863fa0722fb8530dfbb687d2841d221", size = 3657374, upload-time = "2026-04-12T16:25:30.901Z" }, + { url = "https://files.pythonhosted.org/packages/10/18/36e28a809c509a67496202771f545219ac5a2f1cd61aae325991fcf5ab91/lxml-6.0.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:569d3b18340863f603582d2124e742a68e85755eff5e47c26a55e298521e3a01", size = 8575045, upload-time = "2026-04-12T16:25:33.57Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/a168c820e3b08d3b4fa0f4e6b53b3930086b36cc11e428106d38c36778cd/lxml-6.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3b6245ee5241342d45e1a54a4a8bc52ef322333ada74f24aa335c4ab36f20161", size = 4622963, upload-time = "2026-04-12T16:25:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/53/e0/2c9d6abdd82358cea3c0d8d6ca272a6af0f38156abce7827efb6d5b62d17/lxml-6.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79a1173ba3213a3693889a435417d4e9f3c07d96e30dc7cc3a712ed7361015fe", size = 4948832, upload-time = "2026-04-12T16:25:39.104Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/f2202852e91d7baf3a317f4523a9c14834145301e5b0f2e80c01c4bfbd49/lxml-6.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc18bb975666b443ba23aedd2fcf57e9d0d97546b52a1de97a447c4061ba4110", size = 5085865, upload-time = "2026-04-12T16:25:41.226Z" }, + { url = "https://files.pythonhosted.org/packages/09/57/abee549324496e92708f71391c6060a164d3c95369656a1a15e9f20d8162/lxml-6.0.4-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2079f5dc83291ac190a52f8354b78648f221ecac19fb2972a2d056b555824de7", size = 5030001, upload-time = "2026-04-12T16:25:43.695Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/432da7178c5917a16468af6c5da68fef7cf3357d4bd0e6f50272ec9a59b5/lxml-6.0.4-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3eda02da4ca16e9ca22bbe5654470c17fa1abcd967a52e4c2e50ff278221e351", size = 5646303, upload-time = "2026-04-12T16:25:46.577Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/e1c04ef667a6bf9c9dbd3bf04c50fa51d7ee25b258485bb748b27eb9a1c7/lxml-6.0.4-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3787cdc3832b70e21ac2efafea2a82a8ccb5e85bec110dc68b26023e9d3caae", size = 5237940, upload-time = "2026-04-12T16:25:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f0/cdea60d92df731725fc3c4f33e387b100f210acd45c92969e42d2ba993fa/lxml-6.0.4-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:3f276d49c23103565d39440b9b3f4fc08fa22f5a96395ea4b4d4fea4458b1505", size = 5350050, upload-time = "2026-04-12T16:25:52.027Z" }, + { url = "https://files.pythonhosted.org/packages/2e/15/bf52c7a70b6081bb9e00d37cc90fcf60aa84468d9d173ad2fade38ec34c5/lxml-6.0.4-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:fdfdad73736402375b11b3a137e48cd09634177516baf5fc0bd80d1ca85f3cda", size = 4696409, upload-time = "2026-04-12T16:25:55.141Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/9bade267332cc06f9a9aa773b5a11bdfb249af485df9e142993009ea1fc4/lxml-6.0.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75912421456946931daba0ec3cedfa824c756585d05bde97813a17992bfbd013", size = 5249072, upload-time = "2026-04-12T16:25:57.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/ca/043bcacb096d6ed291cbbc58724e9625a453069d6edeb840b0bf18038d05/lxml-6.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:48cd5a88da67233fd82f2920db344503c2818255217cd6ea462c9bb8254ba7cb", size = 5083779, upload-time = "2026-04-12T16:26:00.018Z" }, + { url = "https://files.pythonhosted.org/packages/04/89/f5fb18d76985969e84af13682e489acabee399bb54738a363925ea6e7390/lxml-6.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:87af86a8fa55b9ff1e6ee4233d762296f2ce641ba948af783fb995c5a8a3371b", size = 4736953, upload-time = "2026-04-12T16:26:02.289Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/d1d7284bb4ba951f188c3fc0455943c1fcbd1c33d1324d6d57b7d4a45be6/lxml-6.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a743714cd656ba7ccb29d199783906064c7b5ba3c0e2a79f0244ea0badc6a98c", size = 5669605, upload-time = "2026-04-12T16:26:04.694Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/1463e55f2de27bb60feddc894dd7c0833bd501f8861392ed416291b38db5/lxml-6.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e31c76bd066fb4f81d9a32e5843bffdf939ab27afb1ffc1c924e749bfbdb00e3", size = 5236886, upload-time = "2026-04-12T16:26:07.659Z" }, + { url = "https://files.pythonhosted.org/packages/fe/fb/0b6ee9194ce3ac49db4cadaa8a9158f04779fc768b6c27c4e2945d71a99d/lxml-6.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f185fd6e7d550e9917d7103dccf51be589aba953e15994fb04646c1730019685", size = 5263382, upload-time = "2026-04-12T16:26:10.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/93/ec18a08e98dd82cac39f1d2511ee2bed5affb94d228356d8ef165a4ec3b9/lxml-6.0.4-cp314-cp314-win32.whl", hash = "sha256:774660028f8722a598400430d2746fb0075949f84a9a5cd9767d9152e3baaac5", size = 3656164, upload-time = "2026-04-12T16:26:59.568Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/52507316abfc7150bf6bb191e39a12e301ee80334610a493884ae2f9d20d/lxml-6.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:fbd7d14349413f5609c0b537b1a48117d6ccef1af37986af6b03766ad05bf43e", size = 4062512, upload-time = "2026-04-12T16:27:02.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d5/09c593a2ef2234b8cd6cf059e2dc212e0654bf05c503f0ef2daf05adb680/lxml-6.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:a61a01ec3fbfd5b73a69a7bf513271051fd6c5795d82fc5daa0255934cd8db3d", size = 3740745, upload-time = "2026-04-12T16:27:04.444Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3c/42a98bf6693938bf7b285ec7f70ba2ae9d785d0e5b2cdb85d2ee29e287eb/lxml-6.0.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:504edb62df33cea502ea6e73847c647ba228623ca3f80a228be5723a70984dd5", size = 8826437, upload-time = "2026-04-12T16:26:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c2/ad13f39b2db8709788aa2dcb6e90b81da76db3b5b2e7d35e0946cf984960/lxml-6.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f01b7b0316d4c0926d49a7f003b2d30539f392b140a3374bb788bad180bc8478", size = 4734892, upload-time = "2026-04-12T16:26:15.871Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6d/c559d7b5922c5b0380fc2cb5ac134b6a3f9d79d368347a624ee5d68b0816/lxml-6.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab999933e662501efe4b16e6cfb7c9f9deca7d072cd1788b99c8defde78c0dfb", size = 4969173, upload-time = "2026-04-12T16:26:18.335Z" }, + { url = "https://files.pythonhosted.org/packages/c7/78/ca521e36157f38e3e1a29276855cdf48d213138fc0c8365693ff5c876ca7/lxml-6.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67c3f084389fe75932c39b6869a377f6c8e21e818f31ae8a30c71dd2e59360e2", size = 5103134, upload-time = "2026-04-12T16:26:20.612Z" }, + { url = "https://files.pythonhosted.org/packages/28/a7/7d62d023bacaa0aaf60af8c0a77c6c05f84327396d755f3aa64b788678a9/lxml-6.0.4-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:377ea1d654f76ed6205c87d14920f829c9f4d31df83374d3cbcbdaae804d37b2", size = 5027205, upload-time = "2026-04-12T16:26:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/34/be/51b194b81684f2e85e5d992771c45d70cb22ac6f7291ac6bc7b255830afe/lxml-6.0.4-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e60cd0bcacbfd1a96d63516b622183fb2e3f202300df9eb5533391a8a939dbfa", size = 5594461, upload-time = "2026-04-12T16:26:25.316Z" }, + { url = "https://files.pythonhosted.org/packages/39/24/8850f38fbf89dd072ff31ba22f9e40347aeada7cadf710ecb04b8d9f32d4/lxml-6.0.4-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9e30fd63d41dd0bbdb020af5cdfffd5d9b554d907cb210f18e8fcdc8eac013", size = 5223378, upload-time = "2026-04-12T16:26:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9b/595239ba8c719b0fdc7bc9ebdb7564459c9a6b24b8b363df4a02674aeece/lxml-6.0.4-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:1fb4a1606bb68c533002e7ed50d7e55e58f0ef1696330670281cb79d5ab2050d", size = 5311415, upload-time = "2026-04-12T16:26:31.513Z" }, + { url = "https://files.pythonhosted.org/packages/be/cb/aa27ac8d041acf34691577838494ad08df78e83fdfdb66948d2903e9291e/lxml-6.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:695c7708438e449d57f404db8cc1b769e77ad5b50655f32f8175686ba752f293", size = 4637953, upload-time = "2026-04-12T16:26:33.806Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f2/f19114fd86825c2d1ce41cd99daad218d30cfdd2093d4de9273986fb4d68/lxml-6.0.4-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d49c35ae1e35ee9b569892cf8f8f88db9524f28d66e9daee547a5ef9f3c5f468", size = 5231532, upload-time = "2026-04-12T16:26:36.518Z" }, + { url = "https://files.pythonhosted.org/packages/9a/0e/c3fa354039ec0b6b09f40fbe1129efc572ac6239faa4906de42d5ce87c0a/lxml-6.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5801072f8967625e6249d162065d0d6011ef8ce3d0efb8754496b5246b81a74b", size = 5083767, upload-time = "2026-04-12T16:26:39.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4b/1a0dbb6d6ffae16e54a8a3796ded0ad2f9c3bc1ff3728bde33456f4e1d63/lxml-6.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cbf768541526eba5ef1a49f991122e41b39781eafd0445a5a110fc09947a20b5", size = 4758079, upload-time = "2026-04-12T16:26:42.138Z" }, + { url = "https://files.pythonhosted.org/packages/a9/01/a246cf5f80f96766051de4b305d6552f80bdaefb37f04e019e42af0aba69/lxml-6.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eecce87cc09233786fc31c230268183bf6375126cfec1c8b3673fcdc8767b560", size = 5618686, upload-time = "2026-04-12T16:26:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1f/b072a92369039ebef11b0a654be5134fcf3ed04c0f437faf9435ac9ba845/lxml-6.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:07dce892881179e11053066faca2da17b0eeb0bb7298f11bcf842a86db207dbd", size = 5227259, upload-time = "2026-04-12T16:26:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/dc97034f9d4c0c4d30875147d81fd2c0c7f3d261b109db36ed746bf8ab1d/lxml-6.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e4f97aee337b947e6699e5574c90d087d3e2ce517016241c07e7e98a28dca885", size = 5246190, upload-time = "2026-04-12T16:26:49.468Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ef/85cb69835113583c2516fee07d0ffb4d824b557424b06ba5872c20ba6078/lxml-6.0.4-cp314-cp314t-win32.whl", hash = "sha256:064477c0d4c695aa1ea4b9c1c4ee9043ab740d12135b74c458cc658350adcd86", size = 3896005, upload-time = "2026-04-12T16:26:52.163Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5e/2231f34cc54b8422b793593138d86d3fa4588fb2297d4ea0472390f25627/lxml-6.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:25bad2d8438f4ef5a7ad4a8d8bcaadde20c0daced8bdb56d46236b0a7d1cbdd0", size = 4391037, upload-time = "2026-04-12T16:26:54.398Z" }, + { url = "https://files.pythonhosted.org/packages/39/53/8ba3cd5984f8363635450c93f63e541a0721b362bb32ae0d8237d9674aee/lxml-6.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:1dcd9e6cb9b7df808ea33daebd1801f37a8f50e8c075013ed2a2343246727838", size = 3816184, upload-time = "2026-04-12T16:26:57.011Z" }, + { url = "https://files.pythonhosted.org/packages/41/25/260b86340ec5aadda5e18ed39df0eea61ef8781fb0fcc16c847cdb9dfdff/lxml-6.0.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b29bcca95e82cd201d16c2101085faa2669838f4697fd914b7124a6c77032f80", size = 3929209, upload-time = "2026-04-12T16:28:07.628Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cc/b2157461584525fb0ceb7f4c3b6c1b276f6c7dd34858d78075ae8973bf3d/lxml-6.0.4-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a95e29710ecdf99b446990144598f6117271cb2ec19fd45634aa087892087077", size = 4209535, upload-time = "2026-04-12T16:28:10.071Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/7fdcd1eb31ec0d5871a4a0b1587e78a331f59941ff3af59bed064175499e/lxml-6.0.4-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13085e0174e9c9fa4eb5a6bdfb81646d1f7be07e5895c958e89838afb77630c6", size = 4316979, upload-time = "2026-04-12T16:28:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/53/0c/dab9f5855e7d2e51c8eb461713ada38a7d4eb3ab07fec8d13c46ed353ad6/lxml-6.0.4-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e205c4869a28ec4447375333072978356cd0eeadd0412c643543238e638b89a3", size = 4249929, upload-time = "2026-04-12T16:28:15.739Z" }, + { url = "https://files.pythonhosted.org/packages/a4/88/39e8e4ca7ee1bc9e7cd2f6b311279624afa70a375eef8727f0bb83db2936/lxml-6.0.4-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aec26080306a66ad5c62fad0053dd2170899b465137caca7eac4b72bda3588bf", size = 4399464, upload-time = "2026-04-12T16:28:18.397Z" }, + { url = "https://files.pythonhosted.org/packages/66/54/14c518cc9ce5151fcd1fa95a1c2396799a505dca2c4f0acdf85fb23fe293/lxml-6.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3912221f41d96283b10a7232344351c8511e31f18734c752ed4798c12586ea35", size = 3507404, upload-time = "2026-04-12T16:28:21.188Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631 }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057 }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050 }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681 }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705 }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524 }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282 }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745 }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571 }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056 }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932 }, - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -1339,9 +1339,9 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11'", ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431 } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667 }, + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] [[package]] @@ -1359,9 +1359,9 @@ resolution-markers = [ "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939 }, + { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, ] [[package]] @@ -1371,47 +1371,47 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dill" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/f2/e783ac7f2aeeed14e9e12801f22529cc7e6b7ab80928d6dcce4e9f00922d/multiprocess-0.70.19.tar.gz", hash = "sha256:952021e0e6c55a4a9fe4cd787895b86e239a40e76802a789d6305398d3975897", size = 2079989 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f2/e783ac7f2aeeed14e9e12801f22529cc7e6b7ab80928d6dcce4e9f00922d/multiprocess-0.70.19.tar.gz", hash = "sha256:952021e0e6c55a4a9fe4cd787895b86e239a40e76802a789d6305398d3975897", size = 2079989, upload-time = "2026-01-19T06:47:39.744Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/b6/10832f96b499690854e574360be342a282f5f7dba58eff791299ff6c0637/multiprocess-0.70.19-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:02e5c35d7d6cd2bdc89c1858867f7bde4012837411023a4696c148c1bdd7c80e", size = 135131 }, - { url = "https://files.pythonhosted.org/packages/99/50/faef2d8106534b0dc4a0b772668a1a99682696ebf17d3c0f13f2ed6a656a/multiprocess-0.70.19-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:79576c02d1207ec405b00cabf2c643c36070800cca433860e14539df7818b2aa", size = 135131 }, - { url = "https://files.pythonhosted.org/packages/94/b1/0b71d18b76bf423c2e8ee00b31db37d17297ab3b4db44e188692afdca628/multiprocess-0.70.19-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6b6d78d43a03b68014ca1f0b7937d965393a670c5de7c29026beb2258f2f896", size = 135134 }, - { url = "https://files.pythonhosted.org/packages/7e/aa/714635c727dbfc251139226fa4eaf1b07f00dc12d9cd2eb25f931adaf873/multiprocess-0.70.19-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1bbf1b69af1cf64cd05f65337d9215b88079ec819cd0ea7bac4dab84e162efe7", size = 144743 }, - { url = "https://files.pythonhosted.org/packages/0f/e1/155f6abf5e6b5d9cef29b6d0167c180846157a4aca9b9bee1a217f67c959/multiprocess-0.70.19-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5be9ec7f0c1c49a4f4a6fd20d5dda4aeabc2d39a50f4ad53720f1cd02b3a7c2e", size = 144738 }, - { url = "https://files.pythonhosted.org/packages/af/cb/f421c2869d75750a4f32301cc20c4b63fab6376e9a75c8e5e655bdeb3d9b/multiprocess-0.70.19-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1c3dce098845a0db43b32a0b76a228ca059a668071cfeaa0f40c36c0b1585d45", size = 144741 }, - { url = "https://files.pythonhosted.org/packages/e3/45/8004d1e6b9185c1a444d6b55ac5682acf9d98035e54386d967366035a03a/multiprocess-0.70.19-py310-none-any.whl", hash = "sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87", size = 134948 }, - { url = "https://files.pythonhosted.org/packages/86/c2/dec9722dc3474c164a0b6bcd9a7ed7da542c98af8cabce05374abab35edd/multiprocess-0.70.19-py311-none-any.whl", hash = "sha256:928851ae7973aea4ce0eaf330bbdafb2e01398a91518d5c8818802845564f45c", size = 144457 }, - { url = "https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl", hash = "sha256:3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28", size = 150281 }, - { url = "https://files.pythonhosted.org/packages/7f/74/d2c27e03cb84251dfe7249b8e82923643c6d48fa4883b9476b025e7dc7eb/multiprocess-0.70.19-py313-none-any.whl", hash = "sha256:8d5eb4ec5017ba2fab4e34a747c6d2c2b6fecfe9e7236e77988db91580ada952", size = 156414 }, - { url = "https://files.pythonhosted.org/packages/a0/61/af9115673a5870fd885247e2f1b68c4f1197737da315b520a91c757a861a/multiprocess-0.70.19-py314-none-any.whl", hash = "sha256:e8cc7fbdff15c0613f0a1f1f8744bef961b0a164c0ca29bdff53e9d2d93c5e5f", size = 160318 }, - { url = "https://files.pythonhosted.org/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5", size = 133477 }, + { url = "https://files.pythonhosted.org/packages/8b/b6/10832f96b499690854e574360be342a282f5f7dba58eff791299ff6c0637/multiprocess-0.70.19-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:02e5c35d7d6cd2bdc89c1858867f7bde4012837411023a4696c148c1bdd7c80e", size = 135131, upload-time = "2026-01-19T06:47:20.479Z" }, + { url = "https://files.pythonhosted.org/packages/99/50/faef2d8106534b0dc4a0b772668a1a99682696ebf17d3c0f13f2ed6a656a/multiprocess-0.70.19-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:79576c02d1207ec405b00cabf2c643c36070800cca433860e14539df7818b2aa", size = 135131, upload-time = "2026-01-19T06:47:21.879Z" }, + { url = "https://files.pythonhosted.org/packages/94/b1/0b71d18b76bf423c2e8ee00b31db37d17297ab3b4db44e188692afdca628/multiprocess-0.70.19-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6b6d78d43a03b68014ca1f0b7937d965393a670c5de7c29026beb2258f2f896", size = 135134, upload-time = "2026-01-19T06:47:23.262Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/714635c727dbfc251139226fa4eaf1b07f00dc12d9cd2eb25f931adaf873/multiprocess-0.70.19-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1bbf1b69af1cf64cd05f65337d9215b88079ec819cd0ea7bac4dab84e162efe7", size = 144743, upload-time = "2026-01-19T06:47:24.562Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/155f6abf5e6b5d9cef29b6d0167c180846157a4aca9b9bee1a217f67c959/multiprocess-0.70.19-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5be9ec7f0c1c49a4f4a6fd20d5dda4aeabc2d39a50f4ad53720f1cd02b3a7c2e", size = 144738, upload-time = "2026-01-19T06:47:26.636Z" }, + { url = "https://files.pythonhosted.org/packages/af/cb/f421c2869d75750a4f32301cc20c4b63fab6376e9a75c8e5e655bdeb3d9b/multiprocess-0.70.19-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1c3dce098845a0db43b32a0b76a228ca059a668071cfeaa0f40c36c0b1585d45", size = 144741, upload-time = "2026-01-19T06:47:27.985Z" }, + { url = "https://files.pythonhosted.org/packages/e3/45/8004d1e6b9185c1a444d6b55ac5682acf9d98035e54386d967366035a03a/multiprocess-0.70.19-py310-none-any.whl", hash = "sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87", size = 134948, upload-time = "2026-01-19T06:47:32.325Z" }, + { url = "https://files.pythonhosted.org/packages/86/c2/dec9722dc3474c164a0b6bcd9a7ed7da542c98af8cabce05374abab35edd/multiprocess-0.70.19-py311-none-any.whl", hash = "sha256:928851ae7973aea4ce0eaf330bbdafb2e01398a91518d5c8818802845564f45c", size = 144457, upload-time = "2026-01-19T06:47:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl", hash = "sha256:3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28", size = 150281, upload-time = "2026-01-19T06:47:35.037Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/d2c27e03cb84251dfe7249b8e82923643c6d48fa4883b9476b025e7dc7eb/multiprocess-0.70.19-py313-none-any.whl", hash = "sha256:8d5eb4ec5017ba2fab4e34a747c6d2c2b6fecfe9e7236e77988db91580ada952", size = 156414, upload-time = "2026-01-19T06:47:35.915Z" }, + { url = "https://files.pythonhosted.org/packages/a0/61/af9115673a5870fd885247e2f1b68c4f1197737da315b520a91c757a861a/multiprocess-0.70.19-py314-none-any.whl", hash = "sha256:e8cc7fbdff15c0613f0a1f1f8744bef961b0a164c0ca29bdff53e9d2d93c5e5f", size = 160318, upload-time = "2026-01-19T06:47:37.497Z" }, + { url = "https://files.pythonhosted.org/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5", size = 133477, upload-time = "2026-01-19T06:47:38.619Z" }, ] [[package]] name = "narwhals" version = "2.19.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/1a/bd3317c0bdbcd9ffb710ddf5250b32898f8f2c240be99494fe137feb77a7/narwhals-2.19.0.tar.gz", hash = "sha256:14fd7040b5ff211d415a82e4827b9d04c354e213e72a6d0730205ffd72e3b7ff", size = 623698 } +sdist = { url = "https://files.pythonhosted.org/packages/4e/1a/bd3317c0bdbcd9ffb710ddf5250b32898f8f2c240be99494fe137feb77a7/narwhals-2.19.0.tar.gz", hash = "sha256:14fd7040b5ff211d415a82e4827b9d04c354e213e72a6d0730205ffd72e3b7ff", size = 623698, upload-time = "2026-04-06T15:50:58.786Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/72/e61e3091e0e00fae9d3a8ef85ece9d2cd4b5966058e1f2901ce42679eebf/narwhals-2.19.0-py3-none-any.whl", hash = "sha256:1f8dfa4a33a6dbff878c3e9be4c3b455dfcaf2a9322f1357db00e4e92e95b84b", size = 446991 }, + { url = "https://files.pythonhosted.org/packages/37/72/e61e3091e0e00fae9d3a8ef85ece9d2cd4b5966058e1f2901ce42679eebf/narwhals-2.19.0-py3-none-any.whl", hash = "sha256:1f8dfa4a33a6dbff878c3e9be4c3b455dfcaf2a9322f1357db00e4e92e95b84b", size = 446991, upload-time = "2026-04-06T15:50:57.046Z" }, ] [[package]] name = "nest-asyncio" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] [[package]] name = "nodeenv" version = "1.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611 } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] @@ -1421,62 +1421,62 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11'", ] -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440 } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245 }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048 }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542 }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301 }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320 }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050 }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034 }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185 }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149 }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620 }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963 }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743 }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616 }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579 }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005 }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570 }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548 }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521 }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866 }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455 }, - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348 }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362 }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103 }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382 }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462 }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618 }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511 }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783 }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506 }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190 }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828 }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006 }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765 }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736 }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719 }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072 }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213 }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632 }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532 }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885 }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467 }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144 }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217 }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014 }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935 }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122 }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143 }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260 }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225 }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374 }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391 }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754 }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476 }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666 }, + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, ] [[package]] @@ -1494,79 +1494,79 @@ resolution-markers = [ "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799 }, - { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552 }, - { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566 }, - { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482 }, - { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376 }, - { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137 }, - { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414 }, - { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397 }, - { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499 }, - { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257 }, - { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775 }, - { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272 }, - { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573 }, - { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782 }, - { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038 }, - { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666 }, - { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480 }, - { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036 }, - { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643 }, - { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117 }, - { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584 }, - { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450 }, - { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933 }, - { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532 }, - { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661 }, - { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539 }, - { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806 }, - { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682 }, - { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810 }, - { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394 }, - { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556 }, - { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311 }, - { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060 }, - { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302 }, - { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407 }, - { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631 }, - { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691 }, - { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241 }, - { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767 }, - { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169 }, - { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477 }, - { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487 }, - { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002 }, - { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353 }, - { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914 }, - { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005 }, - { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974 }, - { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591 }, - { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700 }, - { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781 }, - { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959 }, - { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768 }, - { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181 }, - { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035 }, - { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958 }, - { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020 }, - { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758 }, - { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948 }, - { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325 }, - { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883 }, - { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474 }, - { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500 }, - { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755 }, - { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643 }, - { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491 }, - { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830 }, - { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927 }, - { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557 }, - { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253 }, - { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552 }, - { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075 }, + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, ] [[package]] @@ -1576,18 +1576,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060 } +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692 }, + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, ] [[package]] name = "packaging" version = "26.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519 } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831 }, + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] [[package]] @@ -1603,55 +1603,55 @@ dependencies = [ { name = "pytz", marker = "python_full_version < '3.11'" }, { name = "tzdata", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223 } +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763 }, - { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217 }, - { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791 }, - { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373 }, - { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444 }, - { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459 }, - { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086 }, - { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790 }, - { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831 }, - { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267 }, - { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281 }, - { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453 }, - { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361 }, - { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702 }, - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846 }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618 }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212 }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693 }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002 }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971 }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722 }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671 }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807 }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872 }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371 }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333 }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120 }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991 }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227 }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056 }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189 }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912 }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160 }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233 }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635 }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079 }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049 }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638 }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834 }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925 }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071 }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504 }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702 }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535 }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582 }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963 }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175 }, + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] [[package]] @@ -1674,55 +1674,55 @@ dependencies = [ { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855 } +sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/35/6411db530c618e0e0005187e35aa02ce60ae4c4c4d206964a2f978217c27/pandas-3.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a727a73cbdba2f7458dc82449e2315899d5140b449015d822f515749a46cbbe0", size = 10326926 }, - { url = "https://files.pythonhosted.org/packages/c4/d3/b7da1d5d7dbdc5ef52ed7debd2b484313b832982266905315dad5a0bf0b1/pandas-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbbd4aa20ca51e63b53bbde6a0fa4254b1aaabb74d2f542df7a7959feb1d760c", size = 9926987 }, - { url = "https://files.pythonhosted.org/packages/52/77/9b1c2d6070b5dbe239a7bc889e21bfa58720793fb902d1e070695d87c6d0/pandas-3.0.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:339dda302bd8369dedeae979cb750e484d549b563c3f54f3922cb8ff4978c5eb", size = 10757067 }, - { url = "https://files.pythonhosted.org/packages/20/17/ec40d981705654853726e7ac9aea9ddbb4a5d9cf54d8472222f4f3de06c2/pandas-3.0.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61c2fd96d72b983a9891b2598f286befd4ad262161a609c92dc1652544b46b76", size = 11258787 }, - { url = "https://files.pythonhosted.org/packages/90/e3/3f1126d43d3702ca8773871a81c9f15122a1f412342cc56284ffda5b1f70/pandas-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c934008c733b8bbea273ea308b73b3156f0181e5b72960790b09c18a2794fe1e", size = 11771616 }, - { url = "https://files.pythonhosted.org/packages/2e/cf/0f4e268e1f5062e44a6bda9f925806721cd4c95c2b808a4c82ebe914f96b/pandas-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:60a80bb4feacbef5e1447a3f82c33209c8b7e07f28d805cfd1fb951e5cb443aa", size = 12337623 }, - { url = "https://files.pythonhosted.org/packages/44/a0/97a6339859d4acb2536efb24feb6708e82f7d33b2ed7e036f2983fcced82/pandas-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:ed72cb3f45190874eb579c64fa92d9df74e98fd63e2be7f62bce5ace0ade61df", size = 9897372 }, - { url = "https://files.pythonhosted.org/packages/8f/eb/781516b808a99ddf288143cec46b342b3016c3414d137da1fdc3290d8860/pandas-3.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:f12b1a9e332c01e09510586f8ca9b108fd631fd656af82e452d7315ef6df5f9f", size = 9154922 }, - { url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921 }, - { url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127 }, - { url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577 }, - { url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030 }, - { url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468 }, - { url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381 }, - { url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993 }, - { url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118 }, - { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105 }, - { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088 }, - { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066 }, - { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780 }, - { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181 }, - { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899 }, - { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574 }, - { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156 }, - { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238 }, - { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520 }, - { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154 }, - { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449 }, - { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475 }, - { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568 }, - { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652 }, - { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084 }, - { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146 }, - { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081 }, - { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535 }, - { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992 }, - { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257 }, - { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893 }, - { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644 }, - { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246 }, - { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801 }, - { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643 }, - { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641 }, - { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993 }, - { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274 }, - { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530 }, - { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341 }, + { url = "https://files.pythonhosted.org/packages/97/35/6411db530c618e0e0005187e35aa02ce60ae4c4c4d206964a2f978217c27/pandas-3.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a727a73cbdba2f7458dc82449e2315899d5140b449015d822f515749a46cbbe0", size = 10326926, upload-time = "2026-03-31T06:46:08.29Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d3/b7da1d5d7dbdc5ef52ed7debd2b484313b832982266905315dad5a0bf0b1/pandas-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbbd4aa20ca51e63b53bbde6a0fa4254b1aaabb74d2f542df7a7959feb1d760c", size = 9926987, upload-time = "2026-03-31T06:46:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/52/77/9b1c2d6070b5dbe239a7bc889e21bfa58720793fb902d1e070695d87c6d0/pandas-3.0.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:339dda302bd8369dedeae979cb750e484d549b563c3f54f3922cb8ff4978c5eb", size = 10757067, upload-time = "2026-03-31T06:46:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/20/17/ec40d981705654853726e7ac9aea9ddbb4a5d9cf54d8472222f4f3de06c2/pandas-3.0.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61c2fd96d72b983a9891b2598f286befd4ad262161a609c92dc1652544b46b76", size = 11258787, upload-time = "2026-03-31T06:46:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/90/e3/3f1126d43d3702ca8773871a81c9f15122a1f412342cc56284ffda5b1f70/pandas-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c934008c733b8bbea273ea308b73b3156f0181e5b72960790b09c18a2794fe1e", size = 11771616, upload-time = "2026-03-31T06:46:20.532Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cf/0f4e268e1f5062e44a6bda9f925806721cd4c95c2b808a4c82ebe914f96b/pandas-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:60a80bb4feacbef5e1447a3f82c33209c8b7e07f28d805cfd1fb951e5cb443aa", size = 12337623, upload-time = "2026-03-31T06:46:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/97a6339859d4acb2536efb24feb6708e82f7d33b2ed7e036f2983fcced82/pandas-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:ed72cb3f45190874eb579c64fa92d9df74e98fd63e2be7f62bce5ace0ade61df", size = 9897372, upload-time = "2026-03-31T06:46:26.703Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/781516b808a99ddf288143cec46b342b3016c3414d137da1fdc3290d8860/pandas-3.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:f12b1a9e332c01e09510586f8ca9b108fd631fd656af82e452d7315ef6df5f9f", size = 9154922, upload-time = "2026-03-31T06:46:30.284Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921, upload-time = "2026-03-31T06:46:33.36Z" }, + { url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127, upload-time = "2026-03-31T06:46:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577, upload-time = "2026-03-31T06:46:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" }, + { url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, + { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, + { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, + { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, + { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, + { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, + { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, + { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, + { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, ] [[package]] @@ -1732,18 +1732,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/e8/1b856488736db9f1bb8578480f9497f7cf1ede96c6e6e17f29ebef4e78bc/percy-2.0.2.tar.gz", hash = "sha256:6238612dc401fa5c221c0ad7738f7ea43e48fe2695f6423e785ee2bc940f021d", size = 67855 } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e8/1b856488736db9f1bb8578480f9497f7cf1ede96c6e6e17f29ebef4e78bc/percy-2.0.2.tar.gz", hash = "sha256:6238612dc401fa5c221c0ad7738f7ea43e48fe2695f6423e785ee2bc940f021d", size = 67855, upload-time = "2019-03-07T00:28:30.78Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/39/528e689e21baa4d625cecc326c0d56c976d63855f9d114c07ccaed8a73eb/percy-2.0.2-py2.py3-none-any.whl", hash = "sha256:c1647b768810e9453220a7721a5d52cec560dee913d13c1e29b713703f4f223e", size = 12004 }, + { url = "https://files.pythonhosted.org/packages/d6/39/528e689e21baa4d625cecc326c0d56c976d63855f9d114c07ccaed8a73eb/percy-2.0.2-py2.py3-none-any.whl", hash = "sha256:c1647b768810e9453220a7721a5d52cec560dee913d13c1e29b713703f4f223e", size = 12004, upload-time = "2019-03-07T00:28:33.207Z" }, ] [[package]] name = "platformdirs" version = "4.9.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400 } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348 }, + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] [[package]] @@ -1754,9 +1754,9 @@ dependencies = [ { name = "narwhals" }, { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444 }, + { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" }, ] [package.optional-dependencies] @@ -1769,9 +1769,9 @@ express = [ 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 } +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 }, + { 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]] @@ -1781,25 +1781,25 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/ab/f19e592fce9e000da49c96bf35e77cef67f9cb4b040bfa538a2764c0263e/polars-1.39.3.tar.gz", hash = "sha256:2e016c7f3e8d14fa777ef86fe0477cec6c67023a20ba4c94d6e8431eefe4a63c", size = 728987 } +sdist = { url = "https://files.pythonhosted.org/packages/93/ab/f19e592fce9e000da49c96bf35e77cef67f9cb4b040bfa538a2764c0263e/polars-1.39.3.tar.gz", hash = "sha256:2e016c7f3e8d14fa777ef86fe0477cec6c67023a20ba4c94d6e8431eefe4a63c", size = 728987, upload-time = "2026-03-20T11:16:24.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl", hash = "sha256:c2b955ccc0a08a2bc9259785decf3d5c007b489b523bf2390cf21cec2bb82a56", size = 823985 }, + { url = "https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl", hash = "sha256:c2b955ccc0a08a2bc9259785decf3d5c007b489b523bf2390cf21cec2bb82a56", size = 823985, upload-time = "2026-03-20T11:14:23.619Z" }, ] [[package]] name = "polars-runtime-32" version = "1.39.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/17/39/c8688696bc22b6c501e3b82ef3be10e543c07a785af5660f30997cd22dd2/polars_runtime_32-1.39.3.tar.gz", hash = "sha256:c728e4f469cafab501947585f36311b8fb222d3e934c6209e83791e0df20b29d", size = 2872335 } +sdist = { url = "https://files.pythonhosted.org/packages/17/39/c8688696bc22b6c501e3b82ef3be10e543c07a785af5660f30997cd22dd2/polars_runtime_32-1.39.3.tar.gz", hash = "sha256:c728e4f469cafab501947585f36311b8fb222d3e934c6209e83791e0df20b29d", size = 2872335, upload-time = "2026-03-20T11:16:26.581Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/74/1b41205f7368c9375ab1dea91178eaa20435fe3eff036390a53a7660b416/polars_runtime_32-1.39.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:425c0b220b573fa097b4042edff73114cc6d23432a21dfd2dc41adf329d7d2e9", size = 45273243 }, - { url = "https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef5884711e3c617d7dc93519a7d038e242f5741cfe5fe9afd32d58845d86c562", size = 40842924 }, - { url = "https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06b47f535eb1f97a9a1e5b0053ef50db3a4276e241178e37bbb1a38b1fa53b14", size = 43220650 }, - { url = "https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bc9e13dc1d2e828331f2fe8ccbc9757554dc4933a8d3e85e906b988178f95ed", size = 46877498 }, - { url = "https://files.pythonhosted.org/packages/3c/81/bd5f895919e32c6ab0a7786cd0c0ca961cb03152c47c3645808b54383f31/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:363d49e3a3e638fc943e2b9887940300a7d06789930855a178a4727949259dc2", size = 43380176 }, - { url = "https://files.pythonhosted.org/packages/7a/3e/c86433c3b5ec0315bdfc7640d0c15d41f1216c0103a0eab9a9b5147d6c4c/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7c206bdcc7bc62ea038d6adea8e44b02f0e675e0191a54c810703b4895208ea4", size = 46485933 }, - { url = "https://files.pythonhosted.org/packages/54/ce/200b310cf91f98e652eb6ea09fdb3a9718aa0293ebf113dce325797c8572/polars_runtime_32-1.39.3-cp310-abi3-win_amd64.whl", hash = "sha256:d66ca522517554a883446957539c40dc7b75eb0c2220357fb28bc8940d305339", size = 46995458 }, - { url = "https://files.pythonhosted.org/packages/da/76/2d48927e0aa2abbdde08cbf4a2536883b73277d47fbeca95e952de86df34/polars_runtime_32-1.39.3-cp310-abi3-win_arm64.whl", hash = "sha256:f49f51461de63f13e5dd4eb080421c8f23f856945f3f8bd5b2b1f59da52c2860", size = 41857648 }, + { url = "https://files.pythonhosted.org/packages/3b/74/1b41205f7368c9375ab1dea91178eaa20435fe3eff036390a53a7660b416/polars_runtime_32-1.39.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:425c0b220b573fa097b4042edff73114cc6d23432a21dfd2dc41adf329d7d2e9", size = 45273243, upload-time = "2026-03-20T11:14:26.691Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef5884711e3c617d7dc93519a7d038e242f5741cfe5fe9afd32d58845d86c562", size = 40842924, upload-time = "2026-03-20T11:14:31.154Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06b47f535eb1f97a9a1e5b0053ef50db3a4276e241178e37bbb1a38b1fa53b14", size = 43220650, upload-time = "2026-03-20T11:14:35.458Z" }, + { url = "https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bc9e13dc1d2e828331f2fe8ccbc9757554dc4933a8d3e85e906b988178f95ed", size = 46877498, upload-time = "2026-03-20T11:14:40.14Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/bd5f895919e32c6ab0a7786cd0c0ca961cb03152c47c3645808b54383f31/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:363d49e3a3e638fc943e2b9887940300a7d06789930855a178a4727949259dc2", size = 43380176, upload-time = "2026-03-20T11:14:45.566Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3e/c86433c3b5ec0315bdfc7640d0c15d41f1216c0103a0eab9a9b5147d6c4c/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7c206bdcc7bc62ea038d6adea8e44b02f0e675e0191a54c810703b4895208ea4", size = 46485933, upload-time = "2026-03-20T11:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/200b310cf91f98e652eb6ea09fdb3a9718aa0293ebf113dce325797c8572/polars_runtime_32-1.39.3-cp310-abi3-win_amd64.whl", hash = "sha256:d66ca522517554a883446957539c40dc7b75eb0c2220357fb28bc8940d305339", size = 46995458, upload-time = "2026-03-20T11:14:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/da/76/2d48927e0aa2abbdde08cbf4a2536883b73277d47fbeca95e952de86df34/polars_runtime_32-1.39.3-cp310-abi3-win_arm64.whl", hash = "sha256:f49f51461de63f13e5dd4eb080421c8f23f856945f3f8bd5b2b1f59da52c2860", size = 41857648, upload-time = "2026-03-20T11:15:01.142Z" }, ] [[package]] @@ -1813,118 +1813,118 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232 } +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437 }, + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] [[package]] name = "protobuf" version = "7.34.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708 } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247 }, - { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753 }, - { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198 }, - { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267 }, - { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628 }, - { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901 }, - { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715 }, + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, ] [[package]] name = "psutil" version = "7.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595 }, - { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082 }, - { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476 }, - { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062 }, - { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893 }, - { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589 }, - { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664 }, - { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087 }, - { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383 }, - { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210 }, - { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228 }, - { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284 }, - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090 }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859 }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560 }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997 }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972 }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266 }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737 }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617 }, + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] [[package]] name = "pyarrow" version = "23.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336 } +sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/a8/24e5dc6855f50a62936ceb004e6e9645e4219a8065f304145d7fb8a79d5d/pyarrow-23.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:3fab8f82571844eb3c460f90a75583801d14ca0cc32b1acc8c361650e006fd56", size = 34307390 }, - { url = "https://files.pythonhosted.org/packages/bc/8e/4be5617b4aaae0287f621ad31c6036e5f63118cfca0dc57d42121ff49b51/pyarrow-23.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:3f91c038b95f71ddfc865f11d5876c42f343b4495535bd262c7b321b0b94507c", size = 35853761 }, - { url = "https://files.pythonhosted.org/packages/2e/08/3e56a18819462210432ae37d10f5c8eed3828be1d6c751b6e6a2e93c286a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d0744403adabef53c985a7f8a082b502a368510c40d184df349a0a8754533258", size = 44493116 }, - { url = "https://files.pythonhosted.org/packages/f8/82/c40b68001dbec8a3faa4c08cd8c200798ac732d2854537c5449dc859f55a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c33b5bf406284fd0bba436ed6f6c3ebe8e311722b441d89397c54f871c6863a2", size = 47564532 }, - { url = "https://files.pythonhosted.org/packages/20/bc/73f611989116b6f53347581b02177f9f620efdf3cd3f405d0e83cdf53a83/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ddf743e82f69dcd6dbbcb63628895d7161e04e56794ef80550ac6f3315eeb1d5", size = 48183685 }, - { url = "https://files.pythonhosted.org/packages/b0/cc/6c6b3ecdae2a8c3aced99956187e8302fc954cc2cca2a37cf2111dad16ce/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e052a211c5ac9848ae15d5ec875ed0943c0221e2fcfe69eee80b604b4e703222", size = 50605582 }, - { url = "https://files.pythonhosted.org/packages/8d/94/d359e708672878d7638a04a0448edf7c707f9e5606cee11e15aaa5c7535a/pyarrow-23.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5abde149bb3ce524782d838eb67ac095cd3fd6090eba051130589793f1a7f76d", size = 27521148 }, - { url = "https://files.pythonhosted.org/packages/b0/41/8e6b6ef7e225d4ceead8459427a52afdc23379768f54dd3566014d7618c1/pyarrow-23.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", size = 34302230 }, - { url = "https://files.pythonhosted.org/packages/bf/4a/1472c00392f521fea03ae93408bf445cc7bfa1ab81683faf9bc188e36629/pyarrow-23.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", size = 35850050 }, - { url = "https://files.pythonhosted.org/packages/0c/b2/bd1f2f05ded56af7f54d702c8364c9c43cd6abb91b0e9933f3d77b4f4132/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd", size = 44491918 }, - { url = "https://files.pythonhosted.org/packages/0b/62/96459ef5b67957eac38a90f541d1c28833d1b367f014a482cb63f3b7cd2d/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:26d50dee49d741ac0e82185033488d28d35be4d763ae6f321f97d1140eb7a0e9", size = 47562811 }, - { url = "https://files.pythonhosted.org/packages/7d/94/1170e235add1f5f45a954e26cd0e906e7e74e23392dcb560de471f7366ec/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c30143b17161310f151f4a2bcfe41b5ff744238c1039338779424e38579d701", size = 48183766 }, - { url = "https://files.pythonhosted.org/packages/0e/2d/39a42af4570377b99774cdb47f63ee6c7da7616bd55b3d5001aa18edfe4f/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db2190fa79c80a23fdd29fef4b8992893f024ae7c17d2f5f4db7171fa30c2c78", size = 50607669 }, - { url = "https://files.pythonhosted.org/packages/00/ca/db94101c187f3df742133ac837e93b1f269ebdac49427f8310ee40b6a58f/pyarrow-23.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:f00f993a8179e0e1c9713bcc0baf6d6c01326a406a9c23495ec1ba9c9ebf2919", size = 27527698 }, - { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575 }, - { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540 }, - { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940 }, - { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063 }, - { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045 }, - { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741 }, - { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678 }, - { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066 }, - { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526 }, - { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279 }, - { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798 }, - { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446 }, - { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972 }, - { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749 }, - { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544 }, - { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911 }, - { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337 }, - { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944 }, - { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269 }, - { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794 }, - { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642 }, - { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755 }, - { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826 }, - { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859 }, - { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443 }, - { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991 }, - { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077 }, - { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271 }, - { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692 }, - { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383 }, - { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119 }, - { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199 }, - { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435 }, - { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149 }, - { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807 }, + { url = "https://files.pythonhosted.org/packages/bc/a8/24e5dc6855f50a62936ceb004e6e9645e4219a8065f304145d7fb8a79d5d/pyarrow-23.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:3fab8f82571844eb3c460f90a75583801d14ca0cc32b1acc8c361650e006fd56", size = 34307390, upload-time = "2026-02-16T10:08:08.654Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8e/4be5617b4aaae0287f621ad31c6036e5f63118cfca0dc57d42121ff49b51/pyarrow-23.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:3f91c038b95f71ddfc865f11d5876c42f343b4495535bd262c7b321b0b94507c", size = 35853761, upload-time = "2026-02-16T10:08:17.811Z" }, + { url = "https://files.pythonhosted.org/packages/2e/08/3e56a18819462210432ae37d10f5c8eed3828be1d6c751b6e6a2e93c286a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d0744403adabef53c985a7f8a082b502a368510c40d184df349a0a8754533258", size = 44493116, upload-time = "2026-02-16T10:08:25.792Z" }, + { url = "https://files.pythonhosted.org/packages/f8/82/c40b68001dbec8a3faa4c08cd8c200798ac732d2854537c5449dc859f55a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c33b5bf406284fd0bba436ed6f6c3ebe8e311722b441d89397c54f871c6863a2", size = 47564532, upload-time = "2026-02-16T10:08:34.27Z" }, + { url = "https://files.pythonhosted.org/packages/20/bc/73f611989116b6f53347581b02177f9f620efdf3cd3f405d0e83cdf53a83/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ddf743e82f69dcd6dbbcb63628895d7161e04e56794ef80550ac6f3315eeb1d5", size = 48183685, upload-time = "2026-02-16T10:08:42.889Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cc/6c6b3ecdae2a8c3aced99956187e8302fc954cc2cca2a37cf2111dad16ce/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e052a211c5ac9848ae15d5ec875ed0943c0221e2fcfe69eee80b604b4e703222", size = 50605582, upload-time = "2026-02-16T10:08:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/8d/94/d359e708672878d7638a04a0448edf7c707f9e5606cee11e15aaa5c7535a/pyarrow-23.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5abde149bb3ce524782d838eb67ac095cd3fd6090eba051130589793f1a7f76d", size = 27521148, upload-time = "2026-02-16T10:08:58.077Z" }, + { url = "https://files.pythonhosted.org/packages/b0/41/8e6b6ef7e225d4ceead8459427a52afdc23379768f54dd3566014d7618c1/pyarrow-23.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", size = 34302230, upload-time = "2026-02-16T10:09:03.859Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4a/1472c00392f521fea03ae93408bf445cc7bfa1ab81683faf9bc188e36629/pyarrow-23.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", size = 35850050, upload-time = "2026-02-16T10:09:11.877Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b2/bd1f2f05ded56af7f54d702c8364c9c43cd6abb91b0e9933f3d77b4f4132/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd", size = 44491918, upload-time = "2026-02-16T10:09:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/0b/62/96459ef5b67957eac38a90f541d1c28833d1b367f014a482cb63f3b7cd2d/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:26d50dee49d741ac0e82185033488d28d35be4d763ae6f321f97d1140eb7a0e9", size = 47562811, upload-time = "2026-02-16T10:09:25.792Z" }, + { url = "https://files.pythonhosted.org/packages/7d/94/1170e235add1f5f45a954e26cd0e906e7e74e23392dcb560de471f7366ec/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c30143b17161310f151f4a2bcfe41b5ff744238c1039338779424e38579d701", size = 48183766, upload-time = "2026-02-16T10:09:34.645Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/39a42af4570377b99774cdb47f63ee6c7da7616bd55b3d5001aa18edfe4f/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db2190fa79c80a23fdd29fef4b8992893f024ae7c17d2f5f4db7171fa30c2c78", size = 50607669, upload-time = "2026-02-16T10:09:44.153Z" }, + { url = "https://files.pythonhosted.org/packages/00/ca/db94101c187f3df742133ac837e93b1f269ebdac49427f8310ee40b6a58f/pyarrow-23.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:f00f993a8179e0e1c9713bcc0baf6d6c01326a406a9c23495ec1ba9c9ebf2919", size = 27527698, upload-time = "2026-02-16T10:09:50.263Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, + { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, + { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, + { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" }, + { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" }, + { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" }, + { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" }, + { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" }, + { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" }, + { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" }, + { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" }, + { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" }, + { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" }, + { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, ] [[package]] name = "pycparser" version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] @@ -1937,9 +1937,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/6b/69fd5c7194b21ebde0f8637e2a4ddc766ada29d472bfa6a5ca533d79549a/pydantic-2.13.0.tar.gz", hash = "sha256:b89b575b6e670ebf6e7448c01b41b244f471edd276cd0b6fe02e7e7aca320070", size = 843468 } +sdist = { url = "https://files.pythonhosted.org/packages/84/6b/69fd5c7194b21ebde0f8637e2a4ddc766ada29d472bfa6a5ca533d79549a/pydantic-2.13.0.tar.gz", hash = "sha256:b89b575b6e670ebf6e7448c01b41b244f471edd276cd0b6fe02e7e7aca320070", size = 843468, upload-time = "2026-04-13T10:51:35.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/d7/c3a52c61f5b7be648e919005820fbac33028c6149994cd64453f49951c17/pydantic-2.13.0-py3-none-any.whl", hash = "sha256:ab0078b90da5f3e2fd2e71e3d9b457ddcb35d0350854fbda93b451e28d56baaf", size = 471872 }, + { url = "https://files.pythonhosted.org/packages/01/d7/c3a52c61f5b7be648e919005820fbac33028c6149994cd64453f49951c17/pydantic-2.13.0-py3-none-any.whl", hash = "sha256:ab0078b90da5f3e2fd2e71e3d9b457ddcb35d0350854fbda93b451e28d56baaf", size = 471872, upload-time = "2026-04-13T10:51:33.343Z" }, ] [[package]] @@ -1949,114 +1949,122 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/0a/9414cddf82eda3976b14048cc0fa8f5b5d1aecb0b22e1dcd2dbfe0e139b1/pydantic_core-2.46.0.tar.gz", hash = "sha256:82d2498c96be47b47e903e1378d1d0f770097ec56ea953322f39936a7cf34977", size = 471441 } +sdist = { url = "https://files.pythonhosted.org/packages/6f/0a/9414cddf82eda3976b14048cc0fa8f5b5d1aecb0b22e1dcd2dbfe0e139b1/pydantic_core-2.46.0.tar.gz", hash = "sha256:82d2498c96be47b47e903e1378d1d0f770097ec56ea953322f39936a7cf34977", size = 471441, upload-time = "2026-04-13T09:06:33.813Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/17/fd3ba2f035ac7b3a1ae0c55e5c0f6eb5275e87ad80a9b277cb2e70317e2c/pydantic_core-2.46.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d449eae37d6b066d8a8be0e3a7d7041712d6e9152869e7d03c203795aae44ed", size = 2122942 }, - { url = "https://files.pythonhosted.org/packages/01/b5/214cb10e4050f430f383a21496087c1e51d583eec3c884b0e5f55c34eb69/pydantic_core-2.46.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4f7bfc1ffee4ddc03c2db472c7607a238dbbf76f7f64104fc6a623d47fb8e310", size = 1949068 }, - { url = "https://files.pythonhosted.org/packages/b4/ab/8ab4ec2a879eead4bb51c3e9af65583e16cc504867e808909cd4f991a5ae/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a30f5d1d4e1c958b44b5c777a0d1adcd930429f35101e4780281ffbe11103925", size = 1974362 }, - { url = "https://files.pythonhosted.org/packages/8f/dd/dc8ef47e18ddcab169af68b3c11648e1ef85c56aa18e2f96312cc5442404/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f68e12d2de32ac6313a7d3854f346d71731288184fbbfc9004e368714244d2cd", size = 2043754 }, - { url = "https://files.pythonhosted.org/packages/7f/52/69195c8f6549d2b1b9ce0efbb9bf169b47dcb9a60f81ff53a67cb22d8fc7/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d1a058fb5aff8a1a221e7d8a0cf5b0133d069b2f293cb05f174c61bc7cdac34", size = 2230099 }, - { url = "https://files.pythonhosted.org/packages/2a/41/48c8e7709604a4230f86f77bc17e1eb575e0894831f2c3beaecb3e8f7583/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd01128431f355e309267283e37e23704f24558e9059d930e213a377b1be919", size = 2293730 }, - { url = "https://files.pythonhosted.org/packages/08/ab/f3bc576d37eb3036f7b1b2721ab0f89e4684fab48e1de1d0eca0dfef7469/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7747a50d9f75fe264b9e2091a2f462a7dd400add8723a87a75240106b6f4d949", size = 2095380 }, - { url = "https://files.pythonhosted.org/packages/fe/69/0f6e5bd9c5594b41deb91029ad0b16ffe5a270dd412033dd1135a40bbfa3/pydantic_core-2.46.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:1d9b841e9c82a9cdf397a720bb8a4f2d6da6780204e1eb07c2d90c4b5b791b0d", size = 2140115 }, - { url = "https://files.pythonhosted.org/packages/28/7c/79cfc18d352797b84a7c5b27171d6557121843729bc637a90550d08370fd/pydantic_core-2.46.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:61d0f5951b7b86ec24e24fe0c5a2cce7c360830026dfbe004954e8fac9918b95", size = 2183044 }, - { url = "https://files.pythonhosted.org/packages/59/bc/701b17bf7fd375e59e03838cffe8f6893498503b7d412d577ffd92dab56c/pydantic_core-2.46.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:aec0be48d2555ceac04905ffb8f2bb7e55a56644858891196191827b6fc656b7", size = 2185277 }, - { url = "https://files.pythonhosted.org/packages/c3/43/ad927b8861ab787b4189ddb2dd70ebcdc20c5a4baf52df94934d6f87d730/pydantic_core-2.46.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:2c1ec2ced44a8a479d71a14f5be35461360acd388987873a8e0a02f7f81c8ec2", size = 2329998 }, - { url = "https://files.pythonhosted.org/packages/47/33/ad11d56b97ea986f991da998d551a7513d19c06ed05a529e86520430e10e/pydantic_core-2.46.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5e157a25eed281f5e40119078e3dbf698c28b3d88ff0176eea3dd37191447b8d", size = 2369004 }, - { url = "https://files.pythonhosted.org/packages/16/d1/a9a28a122f1227dc13fdd361d77a3f2df4aee64e4ac5693d7ce74a8ecfa4/pydantic_core-2.46.0-cp310-cp310-win32.whl", hash = "sha256:311929d9bfdb9fdbaf28beb39d88a1e36ca6dc5424ceca6d3bf81c9e1da2313c", size = 1982879 }, - { url = "https://files.pythonhosted.org/packages/94/9a/52988a743cf7a9d84861e380c6a5496589aebbc3592d9ecdecb13c6bd0a2/pydantic_core-2.46.0-cp310-cp310-win_amd64.whl", hash = "sha256:60edfb53b13fbe7be9bb51447016b7bcd8772beb8ca216873be33e9d11b2c8e8", size = 2068907 }, - { url = "https://files.pythonhosted.org/packages/ce/43/9bc38d43a6a48794209e4eb6d61e9c68395f69b7949f66842854b0cd1344/pydantic_core-2.46.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0027da787ae711f7fbd5a76cb0bb8df526acba6c10c1e44581de1b838db10b7b", size = 2121004 }, - { url = "https://files.pythonhosted.org/packages/8c/1d/f43342b7107939b305b5e4efeef7d54e267a5ef51515570a5c1d77726efb/pydantic_core-2.46.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:63e288fc18d7eaeef5f16c73e65c4fd0ad95b25e7e21d8a5da144977b35eb997", size = 1947505 }, - { url = "https://files.pythonhosted.org/packages/4a/cd/ccf48cbbcaf0d99ba65969459ebfbf7037600b2cfdcca3062084dd83a008/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:080a3bdc6807089a1fe1fbc076519cea287f1a964725731d80b49d8ecffaa217", size = 1973301 }, - { url = "https://files.pythonhosted.org/packages/c2/ff/a7bb1e7a762fb1f40ad5ef4e6a92c012864a017b7b1fdfb71cf91faa8b73/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c065f1c3e54c3e79d909927a8cb48ccbc17b68733552161eba3e0628c38e5d19", size = 2042208 }, - { url = "https://files.pythonhosted.org/packages/ea/64/d3f11c6f6ace71526f3b03646df95eaab3f21edd13e00daae3f20f4e5a09/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e2db58ab46cfe602d4255381cce515585998c3b6699d5b1f909f519bc44a5aa", size = 2229046 }, - { url = "https://files.pythonhosted.org/packages/d0/64/93db9a63cce71630c58b376d63de498aa93cb341c72cd5f189b5c08f5c28/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c660974890ec1e4c65cff93f5670a5f451039f65463e9f9c03ad49746b49fc78", size = 2292138 }, - { url = "https://files.pythonhosted.org/packages/e9/96/936fccce22f1f2ae8b2b694de651c2c929847be5f701c927a0bb3b1eb679/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3be91482a8db77377c902cca87697388a4fb68addeb3e943ac74f425201a099", size = 2093333 }, - { url = "https://files.pythonhosted.org/packages/75/76/c325e7fda69d589e26e772272044fe704c7e525c47d0d32a74f8345ac657/pydantic_core-2.46.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:1c72de82115233112d70d07f26a48cf6996eb86f7e143423ec1a182148455a9d", size = 2138802 }, - { url = "https://files.pythonhosted.org/packages/c0/6f/ccaa2ff7d53a017b66841e2d38edd1f38d19ae1a2d0c5efee17f2d432229/pydantic_core-2.46.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7904e58768cd79304b992868d7710bfc85dc6c7ed6163f0f68dbc1dcd72dc231", size = 2181358 }, - { url = "https://files.pythonhosted.org/packages/6c/71/0c4b6303e92d63edcb81f5301695cdf70bb351775b4733eea65acdac8384/pydantic_core-2.46.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1af8d88718005f57bb4768f92f4ff16bf31a747d39dfc919b22211b84e72c053", size = 2183985 }, - { url = "https://files.pythonhosted.org/packages/71/eb/f6bf255de38a4393aaa10bff224e882b630576bc26ebfb401e42bb965092/pydantic_core-2.46.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:a5b891301b02770a5852253f4b97f8bd192e5710067bc129e20d43db5403ede2", size = 2328559 }, - { url = "https://files.pythonhosted.org/packages/f2/71/93895a1545f50823a24b21d7761c2bd1b1afea7a6ddc019787caec237361/pydantic_core-2.46.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:48b671fe59031fd9754c7384ac05b3ed47a0cccb7d4db0ec56121f0e6a541b90", size = 2367466 }, - { url = "https://files.pythonhosted.org/packages/78/39/62331b3e71f41fb13d486621e2aec49900ba56567fb3a0ae5999fded0005/pydantic_core-2.46.0-cp311-cp311-win32.whl", hash = "sha256:0a52b7262b6cc67033823e9549a41bb77580ac299dc964baae4e9c182b2e335c", size = 1981367 }, - { url = "https://files.pythonhosted.org/packages/9f/51/caac70958420e2d6115962f550676df59647c11f96a44c2fcb61662fcd16/pydantic_core-2.46.0-cp311-cp311-win_amd64.whl", hash = "sha256:4103fea1beeef6b3a9fed8515f27d4fa30c929a1973655adf8f454dc49ee0662", size = 2065942 }, - { url = "https://files.pythonhosted.org/packages/b2/cf/576b2a4eb5500a1a5da485613b1ea8bc0d7279b27e0426801574b284ae65/pydantic_core-2.46.0-cp311-cp311-win_arm64.whl", hash = "sha256:3137cd88938adb8e567c5e938e486adc7e518ffc96b4ae1ec268e6a4275704d7", size = 2052532 }, - { url = "https://files.pythonhosted.org/packages/a7/d2/206c72ad47071559142a35f71efc29eb16448a4a5ae9487230ab8e4e292b/pydantic_core-2.46.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66ccedb02c934622612448489824955838a221b3a35875458970521ef17b2f9c", size = 2117060 }, - { url = "https://files.pythonhosted.org/packages/17/2c/7a53b33f91c8b77e696b1a6aa3bed609bf9374bdc0f8dcda681bc7d922b8/pydantic_core-2.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a44f27f4d2788ef9876ec47a43739b118c5904d74f418f53398f6ced3bbcacf2", size = 1951802 }, - { url = "https://files.pythonhosted.org/packages/fc/20/90e548c1f6d38800ef11c915881525770ce270d8e5e887563ff046a08674/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26a1032bcce6ca4b4670eb3f7d8195bd0a8b8f255f1307823e217ca3cfa7c27", size = 1976621 }, - { url = "https://files.pythonhosted.org/packages/20/3c/9c5810ca70b60c623488cdd80f7e9ee1a0812df81e97098b64788719860f/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b8d1412f725060527e56675904b17a2d421dddcf861eecf7c75b9dda47921a4", size = 2056721 }, - { url = "https://files.pythonhosted.org/packages/1a/a3/d6e5f4cdec84278431c75540f90838c9d0a4dfe9402a8f3902073660ff28/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc3d1569edd859cabaa476cabce9eecd05049a7966af7b4a33b541bfd4ca1104", size = 2239634 }, - { url = "https://files.pythonhosted.org/packages/46/42/ef58aacf330d8de6e309d62469aa1f80e945eaf665929b4037ac1bfcebc1/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38108976f2d8afaa8f5067fd1390a8c9f5cc580175407cda636e76bc76e88054", size = 2315739 }, - { url = "https://files.pythonhosted.org/packages/8b/86/c63b12fafa2d86a515bfd1840b39c23a49302f02b653161bf9c3a0566c50/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5a06d8ed01dad5575056b5187e5959b336793c6047920a3441ee5b03533836", size = 2098169 }, - { url = "https://files.pythonhosted.org/packages/76/19/b5b33a2f6be4755b21a20434293c4364be255f4c1a108f125d101d4cc4ee/pydantic_core-2.46.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:04017ace142da9ce27cafd423a480872571b5c7e80382aec22f7d715ca8eb870", size = 2170830 }, - { url = "https://files.pythonhosted.org/packages/99/ae/7559f99a29b7d440012ddb4da897359304988a881efaca912fd2f655652e/pydantic_core-2.46.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2629ad992ed1b1c012e6067f5ffafd3336fcb9b54569449fabb85621f1444ed3", size = 2203901 }, - { url = "https://files.pythonhosted.org/packages/dd/0e/b0ef945a39aeb4ac58da316813e1106b7fbdfbf20ac141c1c27904355ac5/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3068b1e7bd986aebc88f6859f8353e72072538dcf92a7fb9cf511a0f61c5e729", size = 2191789 }, - { url = "https://files.pythonhosted.org/packages/90/f4/830484e07188c1236b013995818888ab93bab8fd88aa9689b1d8fd22220d/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:1e366916ff69ff700aa9326601634e688581bc24c5b6b4f8738d809ec7d72611", size = 2344423 }, - { url = "https://files.pythonhosted.org/packages/fd/ba/e455c18cbdc333177af754e740be4fe9d1de173d65bbe534daf88da02ac0/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:485a23e8f4618a1b8e23ac744180acde283fffe617f96923d25507d5cade62ec", size = 2384037 }, - { url = "https://files.pythonhosted.org/packages/78/1f/b35d20d73144a41e78de0ae398e60fdd8bed91667daa1a5a92ab958551ba/pydantic_core-2.46.0-cp312-cp312-win32.whl", hash = "sha256:520940e1b702fe3b33525d0351777f25e9924f1818ca7956447dabacf2d339fd", size = 1967068 }, - { url = "https://files.pythonhosted.org/packages/d1/84/4b6252e9606e8295647b848233cc4137ee0a04ebba8f0f9fb2977655b38c/pydantic_core-2.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:90d2048e0339fa365e5a66aefe760ddd3b3d0a45501e088bc5bc7f4ed9ff9571", size = 2071008 }, - { url = "https://files.pythonhosted.org/packages/39/95/d08eb508d4d5560ccbd226ee5971e5ef9b749aba9b413c0c4ed6e406d4f6/pydantic_core-2.46.0-cp312-cp312-win_arm64.whl", hash = "sha256:a70247649b7dffe36648e8f34be5ce8c5fa0a27ff07b071ea780c20a738c05ce", size = 2036634 }, - { url = "https://files.pythonhosted.org/packages/df/05/ab3b0742bad1d51822f1af0c4232208408902bdcfc47601f3b812e09e6c2/pydantic_core-2.46.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a05900c37264c070c683c650cbca8f83d7cbb549719e645fcd81a24592eac788", size = 2116814 }, - { url = "https://files.pythonhosted.org/packages/98/08/30b43d9569d69094a0899a199711c43aa58fce6ce80f6a8f7693673eb995/pydantic_core-2.46.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de8e482fd4f1e3f36c50c6aac46d044462615d8f12cfafc6bebeaa0909eea22", size = 1951867 }, - { url = "https://files.pythonhosted.org/packages/db/a0/bf9a1ba34537c2ed3872a48195291138fdec8fe26c4009776f00d63cf0c8/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c525ecf8a4cdf198327b65030a7d081867ad8e60acb01a7214fff95cf9832d47", size = 1977040 }, - { url = "https://files.pythonhosted.org/packages/71/70/0ba03c20e1e118219fc18c5417b008b7e880f0e3fb38560ec4465984d471/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f14581aeb12e61542ce73b9bfef2bca5439d65d9ab3efe1a4d8e346b61838f9b", size = 2055284 }, - { url = "https://files.pythonhosted.org/packages/58/cf/1e320acefbde7fb7158a9e5def55e0adf9a4634636098ce28dc6b978e0d3/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c108067f2f7e190d0dbd81247d789ec41f9ea50ccd9265a3a46710796ac60530", size = 2238896 }, - { url = "https://files.pythonhosted.org/packages/df/f5/ea8ba209756abe9eba891bb0ef3772b4c59a894eb9ad86cd5bd0dd4e3e52/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ac10967e9a7bb1b96697374513f9a1a90a59e2fb41566b5e00ee45392beac59", size = 2314353 }, - { url = "https://files.pythonhosted.org/packages/e8/f8/5885350203b72e96438eee7f94de0d8f0442f4627237ca8ef75de34db1cd/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7897078fe8a13b73623c0955dfb2b3d2c9acb7177aac25144758c9e5a5265aaa", size = 2098522 }, - { url = "https://files.pythonhosted.org/packages/bf/88/5930b0e828e371db5a556dd3189565417ddc3d8316bb001058168aadcf5f/pydantic_core-2.46.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:e69ce405510a419a082a78faed65bb4249cfb51232293cc675645c12f7379bf7", size = 2168757 }, - { url = "https://files.pythonhosted.org/packages/da/75/63d563d3035a0548e721c38b5b69fd5626fdd51da0f09ff4467503915b82/pydantic_core-2.46.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd28d13eea0d8cf351dc1fe274b5070cc8e1cca2644381dee5f99de629e77cf3", size = 2202518 }, - { url = "https://files.pythonhosted.org/packages/a7/53/1958eacbfddc41aadf5ae86dd85041bf054b675f34a2fa76385935f96070/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ee1547a6b8243e73dd10f585555e5a263395e55ce6dea618a078570a1e889aef", size = 2190148 }, - { url = "https://files.pythonhosted.org/packages/c7/17/098cc6d3595e4623186f2bc6604a6195eb182e126702a90517236391e9ce/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c3dc68dcf62db22a18ddfc3ad4960038f72b75908edc48ae014d7ac8b391d57a", size = 2342925 }, - { url = "https://files.pythonhosted.org/packages/71/a7/abdb924620b1ac535c690b36ad5b8871f376104090f8842c08625cecf1d3/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:004a2081c881abfcc6854a4623da6a09090a0d7c1398a6ae7133ca1256cee70b", size = 2383167 }, - { url = "https://files.pythonhosted.org/packages/d7/c9/2ddd10f50e4b7350d2574629a0f53d8d4eb6573f9c19a6b43e6b1487a31d/pydantic_core-2.46.0-cp313-cp313-win32.whl", hash = "sha256:59d24ec8d5eaabad93097525a69d0f00f2667cb353eb6cda578b1cfff203ceef", size = 1965660 }, - { url = "https://files.pythonhosted.org/packages/b5/e7/1efc38ed6f2680c032bcefa0e3ebd496a8c77e92dfdb86b07d0f2fc632b1/pydantic_core-2.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:71186dad5ac325c64d68fe0e654e15fd79802e7cc42bc6f0ff822d5ad8b1ab25", size = 2069563 }, - { url = "https://files.pythonhosted.org/packages/c3/1e/a325b4989e742bf7e72ed35fa124bc611fd76539c9f8cd2a9a7854473533/pydantic_core-2.46.0-cp313-cp313-win_arm64.whl", hash = "sha256:8e4503f3213f723842c9a3b53955c88a9cfbd0b288cbd1c1ae933aebeec4a1b4", size = 2034966 }, - { url = "https://files.pythonhosted.org/packages/36/3b/914891d384cdbf9a6f464eb13713baa22ea1e453d4da80fb7da522079370/pydantic_core-2.46.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4fc801c290342350ffc82d77872054a934b2e24163727263362170c1db5416ca", size = 2113349 }, - { url = "https://files.pythonhosted.org/packages/35/95/3a0c6f65e231709fb3463e32943c69d10285cb50203a2130a4732053a06d/pydantic_core-2.46.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0a36f2cc88170cc177930afcc633a8c15907ea68b59ac16bd180c2999d714940", size = 1949170 }, - { url = "https://files.pythonhosted.org/packages/d1/63/d845c36a608469fe7bee226edeff0984c33dbfe7aecd755b0e7ab5a275c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a3912e0c568a1f99d4d6d3e41def40179d61424c0ca1c8c87c4877d7f6fd7fb", size = 1977914 }, - { url = "https://files.pythonhosted.org/packages/08/6f/f2e7a7f85931fb31671f5378d1c7fc70606e4b36d59b1b48e1bd1ef5d916/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3534c3415ed1a19ab23096b628916a827f7858ec8db49ad5d7d1e44dc13c0d7b", size = 2050538 }, - { url = "https://files.pythonhosted.org/packages/8c/97/f4aa7181dd9a16dd9059a99fc48fdab0c2aab68307283a5c04cf56de68c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21067396fc285609323a4db2f63a87570044abe0acddfcca8b135fc7948e3db7", size = 2236294 }, - { url = "https://files.pythonhosted.org/packages/24/c1/6a5042fc32765c87101b500f394702890af04239c318b6002cfd627b710d/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2afd85b7be186e2fe7cdbb09a3d964bcc2042f65bbcc64ad800b3c7915032655", size = 2312954 }, - { url = "https://files.pythonhosted.org/packages/cb/e4/566101a561492ce8454f0844ca29c3b675a6b3a7b3ff577db85ed05c8c50/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67e2c2e171b78db8154da602de72ffdc473c6ee51de8a9d80c0f1cd4051abfc7", size = 2102533 }, - { url = "https://files.pythonhosted.org/packages/3e/ac/adc11ee1646a5c4dd9abb09a00e7909e6dc25beddc0b1310ca734bb9b48e/pydantic_core-2.46.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c16ae1f3170267b1a37e16dba5c297bdf60c8b5657b147909ca8774ce7366644", size = 2169447 }, - { url = "https://files.pythonhosted.org/packages/26/73/408e686b45b82d28ac19e8229e07282254dbee6a5d24c5c7cf3cf3716613/pydantic_core-2.46.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:133b69e1c1ba34d3702eed73f19f7f966928f9aa16663b55c2ebce0893cca42e", size = 2200672 }, - { url = "https://files.pythonhosted.org/packages/0a/3b/807d5b035ec891b57b9079ce881f48263936c37bd0d154a056e7fd152afb/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:15ed8e5bde505133d96b41702f31f06829c46b05488211a5b1c7877e11de5eb5", size = 2188293 }, - { url = "https://files.pythonhosted.org/packages/f1/ed/719b307516285099d1196c52769fdbe676fd677da007b9c349ae70b7226d/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:8cfc29a1c66a7f0fcb36262e92f353dd0b9c4061d558fceb022e698a801cb8ae", size = 2335023 }, - { url = "https://files.pythonhosted.org/packages/8d/90/8718e4ae98c4e8a7325afdc079be82be1e131d7a47cb6c098844a9531ffe/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e1155708540f13845bf68d5ac511a55c76cfe2e057ed12b4bf3adac1581fc5c2", size = 2377155 }, - { url = "https://files.pythonhosted.org/packages/dd/dc/7172789283b963f81da2fc92b186e22de55687019079f71c4d570822502b/pydantic_core-2.46.0-cp314-cp314-win32.whl", hash = "sha256:de5635a48df6b2eef161d10ea1bc2626153197333662ba4cd700ee7ec1aba7f5", size = 1963078 }, - { url = "https://files.pythonhosted.org/packages/e0/69/03a7ea4b6264def3a44eabf577528bcec2f49468c5698b2044dea54dc07e/pydantic_core-2.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:f07a5af60c5e7cf53dd1ff734228bd72d0dc9938e64a75b5bb308ca350d9681e", size = 2068439 }, - { url = "https://files.pythonhosted.org/packages/f5/eb/1c3afcfdee2ab6634b802ab0a0f1966df4c8b630028ec56a1cb0a710dc58/pydantic_core-2.46.0-cp314-cp314-win_arm64.whl", hash = "sha256:e7a77eca3c7d5108ff509db20aae6f80d47c7ed7516d8b96c387aacc42f3ce0f", size = 2026470 }, - { url = "https://files.pythonhosted.org/packages/5c/30/1177dde61b200785c4739665e3aa03a9d4b2c25d2d0408b07d585e633965/pydantic_core-2.46.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5e7cdd4398bee1aaeafe049ac366b0f887451d9ae418fd8785219c13fea2f928", size = 2107447 }, - { url = "https://files.pythonhosted.org/packages/b1/60/4e0f61f99bdabbbc309d364a2791e1ba31e778a4935bc43391a7bdec0744/pydantic_core-2.46.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c2c92d82808e27cef3f7ab3ed63d657d0c755e0dbe5b8a58342e37bdf09bd2e", size = 1926927 }, - { url = "https://files.pythonhosted.org/packages/1d/d0/67f89a8269152c1d6eaa81f04e75a507372ebd8ca7382855a065222caa80/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bab80af91cd7014b45d1089303b5f844a9d91d7da60eabf3d5f9694b32a6655", size = 1966613 }, - { url = "https://files.pythonhosted.org/packages/cd/07/8dfdc3edc78f29a80fb31f366c50203ec904cff6a4c923599bf50ac0d0ff/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e49ffdb714bc990f00b39d1ad1d683033875b5af15582f60c1f34ad3eeccfaa", size = 2032902 }, - { url = "https://files.pythonhosted.org/packages/b0/2a/111c5e8fe24f99c46bcad7d3a82a8f6dbc738066e2c72c04c71f827d8c78/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca877240e8dbdeef3a66f751dc41e5a74893767d510c22a22fc5c0199844f0ce", size = 2244456 }, - { url = "https://files.pythonhosted.org/packages/6b/7c/cfc5d11c15a63ece26e148572c77cfbb2c7f08d315a7b63ef0fe0711d753/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87e6843f89ecd2f596d7294e33196c61343186255b9880c4f1b725fde8b0e20d", size = 2294535 }, - { url = "https://files.pythonhosted.org/packages/c4/2c/f0d744e3dab7bd026a3f4670a97a295157cff923a2666d30a15a70a7e3d0/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e20bc5add1dd9bc3b9a3600d40632e679376569098345500799a6ad7c5d46c72", size = 2104621 }, - { url = "https://files.pythonhosted.org/packages/a7/64/e7cc4698dc024264d214b51d5a47a2404221b12060dd537d76f831b2120a/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:ee6ff79a5f0289d64a9d6696a3ce1f98f925b803dd538335a118231e26d6d827", size = 2130718 }, - { url = "https://files.pythonhosted.org/packages/0b/a8/224e655fec21f7d4441438ad2ecaccb33b5a3876ce7bb2098c74a49efc14/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:52d35cfb58c26323101c7065508d7bb69bb56338cda9ea47a7b32be581af055d", size = 2180738 }, - { url = "https://files.pythonhosted.org/packages/32/7b/b3025618ed4c4e4cbaa9882731c19625db6669896b621760ea95bc1125ef/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d14cc5a6f260fa78e124061eebc5769af6534fc837e9a62a47f09a2c341fa4ea", size = 2171222 }, - { url = "https://files.pythonhosted.org/packages/7b/e3/68170aa1d891920af09c1f2f34df61dc5ff3a746400027155523e3400e89/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:4f7ff859d663b6635f6307a10803d07f0d09487e16c3d36b1744af51dbf948b2", size = 2320040 }, - { url = "https://files.pythonhosted.org/packages/67/1b/5e65807001b84972476300c1f49aea2b4971b7e9fffb5c2654877dadd274/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8ef749be6ed0d69dba31902aaa8255a9bb269ae50c93888c4df242d8bb7acd9e", size = 2377062 }, - { url = "https://files.pythonhosted.org/packages/75/03/48caa9dd5f28f7662bd52bff454d9a451f6b7e5e4af95e289e5e170749c9/pydantic_core-2.46.0-cp314-cp314t-win32.whl", hash = "sha256:d93ca72870133f86360e4bb0c78cd4e6ba2a0f9f3738a6486909ffc031463b32", size = 1951028 }, - { url = "https://files.pythonhosted.org/packages/87/ed/e97ff55fe28c0e6e3cba641d622b15e071370b70e5f07c496b07b65db7c9/pydantic_core-2.46.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ebb2668afd657e2127cb40f2ceb627dd78e74e9dfde14d9bf6cdd532a29ff59", size = 2048519 }, - { url = "https://files.pythonhosted.org/packages/b6/51/e0db8267a287994546925f252e329eeae4121b1e77e76353418da5a3adf0/pydantic_core-2.46.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4864f5bbb7993845baf9209bae1669a8a76769296a018cb569ebda9dcb4241f5", size = 2026791 }, - { url = "https://files.pythonhosted.org/packages/09/ed/fbd8127e4a19c4fdbb2f4983cf72c7b3534086df640c813c5c0ec4218177/pydantic_core-2.46.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:be3e04979ba4d68183f247202c7f4f483f35df57690b3f875c06340a1579b47c", size = 2119951 }, - { url = "https://files.pythonhosted.org/packages/ec/77/df8711ebb45910412f90d75198430fa1120f5618336b71fa00303601c5a4/pydantic_core-2.46.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1eae8d7d9b8c2a90b34d3d9014804dca534f7f40180197062634499412ea14e", size = 1953812 }, - { url = "https://files.pythonhosted.org/packages/12/fe/14b35df69112bd812d6818a395eeab22eeaa2befc6f85bc54ed648430186/pydantic_core-2.46.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a95a2773680dd4b6b999d4eccdd1b577fd71c31739fb4849f6ada47eabb9c56", size = 2139585 }, - { url = "https://files.pythonhosted.org/packages/1f/f0/4fea4c14ebbdeb87e5f6edd2620735fcbd384865f06707fe229c021ce041/pydantic_core-2.46.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25988c3159bb097e06abfdf7b21b1fcaf90f187c74ca6c7bb842c1f72ce74fa8", size = 2179154 }, - { url = "https://files.pythonhosted.org/packages/5c/36/6329aa79ba32b73560e6e453164fb29702b115fd3b2b650e796e1dc27862/pydantic_core-2.46.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:747d89bd691854c719a3381ba46b6124ef916ae85364c79e11db9c84995d8d03", size = 2182917 }, - { url = "https://files.pythonhosted.org/packages/92/61/edbf7aea71052d410347846a2ea43394f74651bf6822b8fad8703ca00575/pydantic_core-2.46.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:909a7327b83ca93b372f7d48df0ebc7a975a5191eb0b6e024f503f4902c24124", size = 2327716 }, - { url = "https://files.pythonhosted.org/packages/a4/11/aa5089b941e85294b1d5d526840b18f0d4464f842d43d8999ce50ef881c1/pydantic_core-2.46.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:2f7e6a3752378a69fadf3f5ee8bc5fa082f623703eec0f4e854b12c548322de0", size = 2365925 }, - { url = "https://files.pythonhosted.org/packages/0c/75/e187b0ea247f71f2009d156df88b7d8449c52a38810c9a1bd55dd4871206/pydantic_core-2.46.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ef47ee0a3ac4c2bb25a083b3acafb171f65be4a0ac1e84edef79dd0016e25eaa", size = 2193856 }, + { url = "https://files.pythonhosted.org/packages/d5/17/fd3ba2f035ac7b3a1ae0c55e5c0f6eb5275e87ad80a9b277cb2e70317e2c/pydantic_core-2.46.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d449eae37d6b066d8a8be0e3a7d7041712d6e9152869e7d03c203795aae44ed", size = 2122942, upload-time = "2026-04-13T09:04:32.413Z" }, + { url = "https://files.pythonhosted.org/packages/01/b5/214cb10e4050f430f383a21496087c1e51d583eec3c884b0e5f55c34eb69/pydantic_core-2.46.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4f7bfc1ffee4ddc03c2db472c7607a238dbbf76f7f64104fc6a623d47fb8e310", size = 1949068, upload-time = "2026-04-13T09:05:28.803Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ab/8ab4ec2a879eead4bb51c3e9af65583e16cc504867e808909cd4f991a5ae/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a30f5d1d4e1c958b44b5c777a0d1adcd930429f35101e4780281ffbe11103925", size = 1974362, upload-time = "2026-04-13T09:05:26.894Z" }, + { url = "https://files.pythonhosted.org/packages/8f/dd/dc8ef47e18ddcab169af68b3c11648e1ef85c56aa18e2f96312cc5442404/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f68e12d2de32ac6313a7d3854f346d71731288184fbbfc9004e368714244d2cd", size = 2043754, upload-time = "2026-04-13T09:04:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/69195c8f6549d2b1b9ce0efbb9bf169b47dcb9a60f81ff53a67cb22d8fc7/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d1a058fb5aff8a1a221e7d8a0cf5b0133d069b2f293cb05f174c61bc7cdac34", size = 2230099, upload-time = "2026-04-13T09:04:44.37Z" }, + { url = "https://files.pythonhosted.org/packages/2a/41/48c8e7709604a4230f86f77bc17e1eb575e0894831f2c3beaecb3e8f7583/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd01128431f355e309267283e37e23704f24558e9059d930e213a377b1be919", size = 2293730, upload-time = "2026-04-13T09:04:27.583Z" }, + { url = "https://files.pythonhosted.org/packages/08/ab/f3bc576d37eb3036f7b1b2721ab0f89e4684fab48e1de1d0eca0dfef7469/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7747a50d9f75fe264b9e2091a2f462a7dd400add8723a87a75240106b6f4d949", size = 2095380, upload-time = "2026-04-13T09:04:45.929Z" }, + { url = "https://files.pythonhosted.org/packages/fe/69/0f6e5bd9c5594b41deb91029ad0b16ffe5a270dd412033dd1135a40bbfa3/pydantic_core-2.46.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:1d9b841e9c82a9cdf397a720bb8a4f2d6da6780204e1eb07c2d90c4b5b791b0d", size = 2140115, upload-time = "2026-04-13T09:07:00.944Z" }, + { url = "https://files.pythonhosted.org/packages/28/7c/79cfc18d352797b84a7c5b27171d6557121843729bc637a90550d08370fd/pydantic_core-2.46.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:61d0f5951b7b86ec24e24fe0c5a2cce7c360830026dfbe004954e8fac9918b95", size = 2183044, upload-time = "2026-04-13T09:03:58.106Z" }, + { url = "https://files.pythonhosted.org/packages/59/bc/701b17bf7fd375e59e03838cffe8f6893498503b7d412d577ffd92dab56c/pydantic_core-2.46.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:aec0be48d2555ceac04905ffb8f2bb7e55a56644858891196191827b6fc656b7", size = 2185277, upload-time = "2026-04-13T09:05:52.482Z" }, + { url = "https://files.pythonhosted.org/packages/c3/43/ad927b8861ab787b4189ddb2dd70ebcdc20c5a4baf52df94934d6f87d730/pydantic_core-2.46.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:2c1ec2ced44a8a479d71a14f5be35461360acd388987873a8e0a02f7f81c8ec2", size = 2329998, upload-time = "2026-04-13T09:05:54.803Z" }, + { url = "https://files.pythonhosted.org/packages/47/33/ad11d56b97ea986f991da998d551a7513d19c06ed05a529e86520430e10e/pydantic_core-2.46.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5e157a25eed281f5e40119078e3dbf698c28b3d88ff0176eea3dd37191447b8d", size = 2369004, upload-time = "2026-04-13T09:05:14.052Z" }, + { url = "https://files.pythonhosted.org/packages/16/d1/a9a28a122f1227dc13fdd361d77a3f2df4aee64e4ac5693d7ce74a8ecfa4/pydantic_core-2.46.0-cp310-cp310-win32.whl", hash = "sha256:311929d9bfdb9fdbaf28beb39d88a1e36ca6dc5424ceca6d3bf81c9e1da2313c", size = 1982879, upload-time = "2026-04-13T09:05:19.277Z" }, + { url = "https://files.pythonhosted.org/packages/94/9a/52988a743cf7a9d84861e380c6a5496589aebbc3592d9ecdecb13c6bd0a2/pydantic_core-2.46.0-cp310-cp310-win_amd64.whl", hash = "sha256:60edfb53b13fbe7be9bb51447016b7bcd8772beb8ca216873be33e9d11b2c8e8", size = 2068907, upload-time = "2026-04-13T09:03:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/ce/43/9bc38d43a6a48794209e4eb6d61e9c68395f69b7949f66842854b0cd1344/pydantic_core-2.46.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0027da787ae711f7fbd5a76cb0bb8df526acba6c10c1e44581de1b838db10b7b", size = 2121004, upload-time = "2026-04-13T09:05:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/8c/1d/f43342b7107939b305b5e4efeef7d54e267a5ef51515570a5c1d77726efb/pydantic_core-2.46.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:63e288fc18d7eaeef5f16c73e65c4fd0ad95b25e7e21d8a5da144977b35eb997", size = 1947505, upload-time = "2026-04-13T09:04:48.975Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cd/ccf48cbbcaf0d99ba65969459ebfbf7037600b2cfdcca3062084dd83a008/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:080a3bdc6807089a1fe1fbc076519cea287f1a964725731d80b49d8ecffaa217", size = 1973301, upload-time = "2026-04-13T09:05:42.149Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/a7bb1e7a762fb1f40ad5ef4e6a92c012864a017b7b1fdfb71cf91faa8b73/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c065f1c3e54c3e79d909927a8cb48ccbc17b68733552161eba3e0628c38e5d19", size = 2042208, upload-time = "2026-04-13T09:05:32.591Z" }, + { url = "https://files.pythonhosted.org/packages/ea/64/d3f11c6f6ace71526f3b03646df95eaab3f21edd13e00daae3f20f4e5a09/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e2db58ab46cfe602d4255381cce515585998c3b6699d5b1f909f519bc44a5aa", size = 2229046, upload-time = "2026-04-13T09:04:18.59Z" }, + { url = "https://files.pythonhosted.org/packages/d0/64/93db9a63cce71630c58b376d63de498aa93cb341c72cd5f189b5c08f5c28/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c660974890ec1e4c65cff93f5670a5f451039f65463e9f9c03ad49746b49fc78", size = 2292138, upload-time = "2026-04-13T09:04:13.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/96/936fccce22f1f2ae8b2b694de651c2c929847be5f701c927a0bb3b1eb679/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3be91482a8db77377c902cca87697388a4fb68addeb3e943ac74f425201a099", size = 2093333, upload-time = "2026-04-13T09:05:15.729Z" }, + { url = "https://files.pythonhosted.org/packages/75/76/c325e7fda69d589e26e772272044fe704c7e525c47d0d32a74f8345ac657/pydantic_core-2.46.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:1c72de82115233112d70d07f26a48cf6996eb86f7e143423ec1a182148455a9d", size = 2138802, upload-time = "2026-04-13T09:03:51.142Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6f/ccaa2ff7d53a017b66841e2d38edd1f38d19ae1a2d0c5efee17f2d432229/pydantic_core-2.46.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7904e58768cd79304b992868d7710bfc85dc6c7ed6163f0f68dbc1dcd72dc231", size = 2181358, upload-time = "2026-04-13T09:04:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/6c/71/0c4b6303e92d63edcb81f5301695cdf70bb351775b4733eea65acdac8384/pydantic_core-2.46.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1af8d88718005f57bb4768f92f4ff16bf31a747d39dfc919b22211b84e72c053", size = 2183985, upload-time = "2026-04-13T09:04:06.792Z" }, + { url = "https://files.pythonhosted.org/packages/71/eb/f6bf255de38a4393aaa10bff224e882b630576bc26ebfb401e42bb965092/pydantic_core-2.46.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:a5b891301b02770a5852253f4b97f8bd192e5710067bc129e20d43db5403ede2", size = 2328559, upload-time = "2026-04-13T09:06:14.143Z" }, + { url = "https://files.pythonhosted.org/packages/f2/71/93895a1545f50823a24b21d7761c2bd1b1afea7a6ddc019787caec237361/pydantic_core-2.46.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:48b671fe59031fd9754c7384ac05b3ed47a0cccb7d4db0ec56121f0e6a541b90", size = 2367466, upload-time = "2026-04-13T09:05:59.613Z" }, + { url = "https://files.pythonhosted.org/packages/78/39/62331b3e71f41fb13d486621e2aec49900ba56567fb3a0ae5999fded0005/pydantic_core-2.46.0-cp311-cp311-win32.whl", hash = "sha256:0a52b7262b6cc67033823e9549a41bb77580ac299dc964baae4e9c182b2e335c", size = 1981367, upload-time = "2026-04-13T09:07:37.563Z" }, + { url = "https://files.pythonhosted.org/packages/9f/51/caac70958420e2d6115962f550676df59647c11f96a44c2fcb61662fcd16/pydantic_core-2.46.0-cp311-cp311-win_amd64.whl", hash = "sha256:4103fea1beeef6b3a9fed8515f27d4fa30c929a1973655adf8f454dc49ee0662", size = 2065942, upload-time = "2026-04-13T09:06:37.873Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cf/576b2a4eb5500a1a5da485613b1ea8bc0d7279b27e0426801574b284ae65/pydantic_core-2.46.0-cp311-cp311-win_arm64.whl", hash = "sha256:3137cd88938adb8e567c5e938e486adc7e518ffc96b4ae1ec268e6a4275704d7", size = 2052532, upload-time = "2026-04-13T09:06:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d2/206c72ad47071559142a35f71efc29eb16448a4a5ae9487230ab8e4e292b/pydantic_core-2.46.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66ccedb02c934622612448489824955838a221b3a35875458970521ef17b2f9c", size = 2117060, upload-time = "2026-04-13T09:04:47.443Z" }, + { url = "https://files.pythonhosted.org/packages/17/2c/7a53b33f91c8b77e696b1a6aa3bed609bf9374bdc0f8dcda681bc7d922b8/pydantic_core-2.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a44f27f4d2788ef9876ec47a43739b118c5904d74f418f53398f6ced3bbcacf2", size = 1951802, upload-time = "2026-04-13T09:05:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/fc/20/90e548c1f6d38800ef11c915881525770ce270d8e5e887563ff046a08674/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26a1032bcce6ca4b4670eb3f7d8195bd0a8b8f255f1307823e217ca3cfa7c27", size = 1976621, upload-time = "2026-04-13T09:04:03.909Z" }, + { url = "https://files.pythonhosted.org/packages/20/3c/9c5810ca70b60c623488cdd80f7e9ee1a0812df81e97098b64788719860f/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b8d1412f725060527e56675904b17a2d421dddcf861eecf7c75b9dda47921a4", size = 2056721, upload-time = "2026-04-13T09:04:40.992Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a3/d6e5f4cdec84278431c75540f90838c9d0a4dfe9402a8f3902073660ff28/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc3d1569edd859cabaa476cabce9eecd05049a7966af7b4a33b541bfd4ca1104", size = 2239634, upload-time = "2026-04-13T09:03:52.478Z" }, + { url = "https://files.pythonhosted.org/packages/46/42/ef58aacf330d8de6e309d62469aa1f80e945eaf665929b4037ac1bfcebc1/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38108976f2d8afaa8f5067fd1390a8c9f5cc580175407cda636e76bc76e88054", size = 2315739, upload-time = "2026-04-13T09:05:04.971Z" }, + { url = "https://files.pythonhosted.org/packages/8b/86/c63b12fafa2d86a515bfd1840b39c23a49302f02b653161bf9c3a0566c50/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5a06d8ed01dad5575056b5187e5959b336793c6047920a3441ee5b03533836", size = 2098169, upload-time = "2026-04-13T09:07:27.151Z" }, + { url = "https://files.pythonhosted.org/packages/76/19/b5b33a2f6be4755b21a20434293c4364be255f4c1a108f125d101d4cc4ee/pydantic_core-2.46.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:04017ace142da9ce27cafd423a480872571b5c7e80382aec22f7d715ca8eb870", size = 2170830, upload-time = "2026-04-13T09:04:39.448Z" }, + { url = "https://files.pythonhosted.org/packages/99/ae/7559f99a29b7d440012ddb4da897359304988a881efaca912fd2f655652e/pydantic_core-2.46.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2629ad992ed1b1c012e6067f5ffafd3336fcb9b54569449fabb85621f1444ed3", size = 2203901, upload-time = "2026-04-13T09:04:01.048Z" }, + { url = "https://files.pythonhosted.org/packages/dd/0e/b0ef945a39aeb4ac58da316813e1106b7fbdfbf20ac141c1c27904355ac5/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3068b1e7bd986aebc88f6859f8353e72072538dcf92a7fb9cf511a0f61c5e729", size = 2191789, upload-time = "2026-04-13T09:06:39.915Z" }, + { url = "https://files.pythonhosted.org/packages/90/f4/830484e07188c1236b013995818888ab93bab8fd88aa9689b1d8fd22220d/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:1e366916ff69ff700aa9326601634e688581bc24c5b6b4f8738d809ec7d72611", size = 2344423, upload-time = "2026-04-13T09:05:12.252Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/e455c18cbdc333177af754e740be4fe9d1de173d65bbe534daf88da02ac0/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:485a23e8f4618a1b8e23ac744180acde283fffe617f96923d25507d5cade62ec", size = 2384037, upload-time = "2026-04-13T09:06:24.503Z" }, + { url = "https://files.pythonhosted.org/packages/78/1f/b35d20d73144a41e78de0ae398e60fdd8bed91667daa1a5a92ab958551ba/pydantic_core-2.46.0-cp312-cp312-win32.whl", hash = "sha256:520940e1b702fe3b33525d0351777f25e9924f1818ca7956447dabacf2d339fd", size = 1967068, upload-time = "2026-04-13T09:05:23.374Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/4b6252e9606e8295647b848233cc4137ee0a04ebba8f0f9fb2977655b38c/pydantic_core-2.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:90d2048e0339fa365e5a66aefe760ddd3b3d0a45501e088bc5bc7f4ed9ff9571", size = 2071008, upload-time = "2026-04-13T09:05:21.392Z" }, + { url = "https://files.pythonhosted.org/packages/39/95/d08eb508d4d5560ccbd226ee5971e5ef9b749aba9b413c0c4ed6e406d4f6/pydantic_core-2.46.0-cp312-cp312-win_arm64.whl", hash = "sha256:a70247649b7dffe36648e8f34be5ce8c5fa0a27ff07b071ea780c20a738c05ce", size = 2036634, upload-time = "2026-04-13T09:05:48.299Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/ab3b0742bad1d51822f1af0c4232208408902bdcfc47601f3b812e09e6c2/pydantic_core-2.46.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a05900c37264c070c683c650cbca8f83d7cbb549719e645fcd81a24592eac788", size = 2116814, upload-time = "2026-04-13T09:04:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/98/08/30b43d9569d69094a0899a199711c43aa58fce6ce80f6a8f7693673eb995/pydantic_core-2.46.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de8e482fd4f1e3f36c50c6aac46d044462615d8f12cfafc6bebeaa0909eea22", size = 1951867, upload-time = "2026-04-13T09:04:02.364Z" }, + { url = "https://files.pythonhosted.org/packages/db/a0/bf9a1ba34537c2ed3872a48195291138fdec8fe26c4009776f00d63cf0c8/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c525ecf8a4cdf198327b65030a7d081867ad8e60acb01a7214fff95cf9832d47", size = 1977040, upload-time = "2026-04-13T09:06:16.088Z" }, + { url = "https://files.pythonhosted.org/packages/71/70/0ba03c20e1e118219fc18c5417b008b7e880f0e3fb38560ec4465984d471/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f14581aeb12e61542ce73b9bfef2bca5439d65d9ab3efe1a4d8e346b61838f9b", size = 2055284, upload-time = "2026-04-13T09:05:25.125Z" }, + { url = "https://files.pythonhosted.org/packages/58/cf/1e320acefbde7fb7158a9e5def55e0adf9a4634636098ce28dc6b978e0d3/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c108067f2f7e190d0dbd81247d789ec41f9ea50ccd9265a3a46710796ac60530", size = 2238896, upload-time = "2026-04-13T09:05:01.345Z" }, + { url = "https://files.pythonhosted.org/packages/df/f5/ea8ba209756abe9eba891bb0ef3772b4c59a894eb9ad86cd5bd0dd4e3e52/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ac10967e9a7bb1b96697374513f9a1a90a59e2fb41566b5e00ee45392beac59", size = 2314353, upload-time = "2026-04-13T09:06:07.942Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/5885350203b72e96438eee7f94de0d8f0442f4627237ca8ef75de34db1cd/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7897078fe8a13b73623c0955dfb2b3d2c9acb7177aac25144758c9e5a5265aaa", size = 2098522, upload-time = "2026-04-13T09:04:23.239Z" }, + { url = "https://files.pythonhosted.org/packages/bf/88/5930b0e828e371db5a556dd3189565417ddc3d8316bb001058168aadcf5f/pydantic_core-2.46.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:e69ce405510a419a082a78faed65bb4249cfb51232293cc675645c12f7379bf7", size = 2168757, upload-time = "2026-04-13T09:07:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/da/75/63d563d3035a0548e721c38b5b69fd5626fdd51da0f09ff4467503915b82/pydantic_core-2.46.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd28d13eea0d8cf351dc1fe274b5070cc8e1cca2644381dee5f99de629e77cf3", size = 2202518, upload-time = "2026-04-13T09:05:44.418Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/1958eacbfddc41aadf5ae86dd85041bf054b675f34a2fa76385935f96070/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ee1547a6b8243e73dd10f585555e5a263395e55ce6dea618a078570a1e889aef", size = 2190148, upload-time = "2026-04-13T09:06:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/17/098cc6d3595e4623186f2bc6604a6195eb182e126702a90517236391e9ce/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c3dc68dcf62db22a18ddfc3ad4960038f72b75908edc48ae014d7ac8b391d57a", size = 2342925, upload-time = "2026-04-13T09:04:17.286Z" }, + { url = "https://files.pythonhosted.org/packages/71/a7/abdb924620b1ac535c690b36ad5b8871f376104090f8842c08625cecf1d3/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:004a2081c881abfcc6854a4623da6a09090a0d7c1398a6ae7133ca1256cee70b", size = 2383167, upload-time = "2026-04-13T09:04:52.643Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c9/2ddd10f50e4b7350d2574629a0f53d8d4eb6573f9c19a6b43e6b1487a31d/pydantic_core-2.46.0-cp313-cp313-win32.whl", hash = "sha256:59d24ec8d5eaabad93097525a69d0f00f2667cb353eb6cda578b1cfff203ceef", size = 1965660, upload-time = "2026-04-13T09:06:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e7/1efc38ed6f2680c032bcefa0e3ebd496a8c77e92dfdb86b07d0f2fc632b1/pydantic_core-2.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:71186dad5ac325c64d68fe0e654e15fd79802e7cc42bc6f0ff822d5ad8b1ab25", size = 2069563, upload-time = "2026-04-13T09:07:14.738Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1e/a325b4989e742bf7e72ed35fa124bc611fd76539c9f8cd2a9a7854473533/pydantic_core-2.46.0-cp313-cp313-win_arm64.whl", hash = "sha256:8e4503f3213f723842c9a3b53955c88a9cfbd0b288cbd1c1ae933aebeec4a1b4", size = 2034966, upload-time = "2026-04-13T09:04:21.629Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/914891d384cdbf9a6f464eb13713baa22ea1e453d4da80fb7da522079370/pydantic_core-2.46.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4fc801c290342350ffc82d77872054a934b2e24163727263362170c1db5416ca", size = 2113349, upload-time = "2026-04-13T09:04:59.407Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/3a0c6f65e231709fb3463e32943c69d10285cb50203a2130a4732053a06d/pydantic_core-2.46.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0a36f2cc88170cc177930afcc633a8c15907ea68b59ac16bd180c2999d714940", size = 1949170, upload-time = "2026-04-13T09:06:09.935Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/d845c36a608469fe7bee226edeff0984c33dbfe7aecd755b0e7ab5a275c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a3912e0c568a1f99d4d6d3e41def40179d61424c0ca1c8c87c4877d7f6fd7fb", size = 1977914, upload-time = "2026-04-13T09:04:56.16Z" }, + { url = "https://files.pythonhosted.org/packages/08/6f/f2e7a7f85931fb31671f5378d1c7fc70606e4b36d59b1b48e1bd1ef5d916/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3534c3415ed1a19ab23096b628916a827f7858ec8db49ad5d7d1e44dc13c0d7b", size = 2050538, upload-time = "2026-04-13T09:05:06.789Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/f4aa7181dd9a16dd9059a99fc48fdab0c2aab68307283a5c04cf56de68c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21067396fc285609323a4db2f63a87570044abe0acddfcca8b135fc7948e3db7", size = 2236294, upload-time = "2026-04-13T09:07:03.2Z" }, + { url = "https://files.pythonhosted.org/packages/24/c1/6a5042fc32765c87101b500f394702890af04239c318b6002cfd627b710d/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2afd85b7be186e2fe7cdbb09a3d964bcc2042f65bbcc64ad800b3c7915032655", size = 2312954, upload-time = "2026-04-13T09:06:11.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e4/566101a561492ce8454f0844ca29c3b675a6b3a7b3ff577db85ed05c8c50/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67e2c2e171b78db8154da602de72ffdc473c6ee51de8a9d80c0f1cd4051abfc7", size = 2102533, upload-time = "2026-04-13T09:06:58.664Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ac/adc11ee1646a5c4dd9abb09a00e7909e6dc25beddc0b1310ca734bb9b48e/pydantic_core-2.46.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c16ae1f3170267b1a37e16dba5c297bdf60c8b5657b147909ca8774ce7366644", size = 2169447, upload-time = "2026-04-13T09:04:11.143Z" }, + { url = "https://files.pythonhosted.org/packages/26/73/408e686b45b82d28ac19e8229e07282254dbee6a5d24c5c7cf3cf3716613/pydantic_core-2.46.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:133b69e1c1ba34d3702eed73f19f7f966928f9aa16663b55c2ebce0893cca42e", size = 2200672, upload-time = "2026-04-13T09:03:54.056Z" }, + { url = "https://files.pythonhosted.org/packages/0a/3b/807d5b035ec891b57b9079ce881f48263936c37bd0d154a056e7fd152afb/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:15ed8e5bde505133d96b41702f31f06829c46b05488211a5b1c7877e11de5eb5", size = 2188293, upload-time = "2026-04-13T09:07:07.614Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ed/719b307516285099d1196c52769fdbe676fd677da007b9c349ae70b7226d/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:8cfc29a1c66a7f0fcb36262e92f353dd0b9c4061d558fceb022e698a801cb8ae", size = 2335023, upload-time = "2026-04-13T09:04:05.176Z" }, + { url = "https://files.pythonhosted.org/packages/8d/90/8718e4ae98c4e8a7325afdc079be82be1e131d7a47cb6c098844a9531ffe/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e1155708540f13845bf68d5ac511a55c76cfe2e057ed12b4bf3adac1581fc5c2", size = 2377155, upload-time = "2026-04-13T09:06:18.081Z" }, + { url = "https://files.pythonhosted.org/packages/dd/dc/7172789283b963f81da2fc92b186e22de55687019079f71c4d570822502b/pydantic_core-2.46.0-cp314-cp314-win32.whl", hash = "sha256:de5635a48df6b2eef161d10ea1bc2626153197333662ba4cd700ee7ec1aba7f5", size = 1963078, upload-time = "2026-04-13T09:05:30.615Z" }, + { url = "https://files.pythonhosted.org/packages/e0/69/03a7ea4b6264def3a44eabf577528bcec2f49468c5698b2044dea54dc07e/pydantic_core-2.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:f07a5af60c5e7cf53dd1ff734228bd72d0dc9938e64a75b5bb308ca350d9681e", size = 2068439, upload-time = "2026-04-13T09:04:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/f5/eb/1c3afcfdee2ab6634b802ab0a0f1966df4c8b630028ec56a1cb0a710dc58/pydantic_core-2.46.0-cp314-cp314-win_arm64.whl", hash = "sha256:e7a77eca3c7d5108ff509db20aae6f80d47c7ed7516d8b96c387aacc42f3ce0f", size = 2026470, upload-time = "2026-04-13T09:05:08.654Z" }, + { url = "https://files.pythonhosted.org/packages/5c/30/1177dde61b200785c4739665e3aa03a9d4b2c25d2d0408b07d585e633965/pydantic_core-2.46.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5e7cdd4398bee1aaeafe049ac366b0f887451d9ae418fd8785219c13fea2f928", size = 2107447, upload-time = "2026-04-13T09:05:46.314Z" }, + { url = "https://files.pythonhosted.org/packages/b1/60/4e0f61f99bdabbbc309d364a2791e1ba31e778a4935bc43391a7bdec0744/pydantic_core-2.46.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c2c92d82808e27cef3f7ab3ed63d657d0c755e0dbe5b8a58342e37bdf09bd2e", size = 1926927, upload-time = "2026-04-13T09:06:20.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d0/67f89a8269152c1d6eaa81f04e75a507372ebd8ca7382855a065222caa80/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bab80af91cd7014b45d1089303b5f844a9d91d7da60eabf3d5f9694b32a6655", size = 1966613, upload-time = "2026-04-13T09:07:05.389Z" }, + { url = "https://files.pythonhosted.org/packages/cd/07/8dfdc3edc78f29a80fb31f366c50203ec904cff6a4c923599bf50ac0d0ff/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e49ffdb714bc990f00b39d1ad1d683033875b5af15582f60c1f34ad3eeccfaa", size = 2032902, upload-time = "2026-04-13T09:06:42.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2a/111c5e8fe24f99c46bcad7d3a82a8f6dbc738066e2c72c04c71f827d8c78/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca877240e8dbdeef3a66f751dc41e5a74893767d510c22a22fc5c0199844f0ce", size = 2244456, upload-time = "2026-04-13T09:05:36.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7c/cfc5d11c15a63ece26e148572c77cfbb2c7f08d315a7b63ef0fe0711d753/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87e6843f89ecd2f596d7294e33196c61343186255b9880c4f1b725fde8b0e20d", size = 2294535, upload-time = "2026-04-13T09:06:01.689Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2c/f0d744e3dab7bd026a3f4670a97a295157cff923a2666d30a15a70a7e3d0/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e20bc5add1dd9bc3b9a3600d40632e679376569098345500799a6ad7c5d46c72", size = 2104621, upload-time = "2026-04-13T09:04:34.388Z" }, + { url = "https://files.pythonhosted.org/packages/a7/64/e7cc4698dc024264d214b51d5a47a2404221b12060dd537d76f831b2120a/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:ee6ff79a5f0289d64a9d6696a3ce1f98f925b803dd538335a118231e26d6d827", size = 2130718, upload-time = "2026-04-13T09:04:26.23Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a8/224e655fec21f7d4441438ad2ecaccb33b5a3876ce7bb2098c74a49efc14/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:52d35cfb58c26323101c7065508d7bb69bb56338cda9ea47a7b32be581af055d", size = 2180738, upload-time = "2026-04-13T09:05:50.253Z" }, + { url = "https://files.pythonhosted.org/packages/32/7b/b3025618ed4c4e4cbaa9882731c19625db6669896b621760ea95bc1125ef/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d14cc5a6f260fa78e124061eebc5769af6534fc837e9a62a47f09a2c341fa4ea", size = 2171222, upload-time = "2026-04-13T09:07:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e3/68170aa1d891920af09c1f2f34df61dc5ff3a746400027155523e3400e89/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:4f7ff859d663b6635f6307a10803d07f0d09487e16c3d36b1744af51dbf948b2", size = 2320040, upload-time = "2026-04-13T09:06:35.732Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/5e65807001b84972476300c1f49aea2b4971b7e9fffb5c2654877dadd274/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8ef749be6ed0d69dba31902aaa8255a9bb269ae50c93888c4df242d8bb7acd9e", size = 2377062, upload-time = "2026-04-13T09:07:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/48caa9dd5f28f7662bd52bff454d9a451f6b7e5e4af95e289e5e170749c9/pydantic_core-2.46.0-cp314-cp314t-win32.whl", hash = "sha256:d93ca72870133f86360e4bb0c78cd4e6ba2a0f9f3738a6486909ffc031463b32", size = 1951028, upload-time = "2026-04-13T09:04:20.224Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/e97ff55fe28c0e6e3cba641d622b15e071370b70e5f07c496b07b65db7c9/pydantic_core-2.46.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ebb2668afd657e2127cb40f2ceb627dd78e74e9dfde14d9bf6cdd532a29ff59", size = 2048519, upload-time = "2026-04-13T09:05:10.464Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/e0db8267a287994546925f252e329eeae4121b1e77e76353418da5a3adf0/pydantic_core-2.46.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4864f5bbb7993845baf9209bae1669a8a76769296a018cb569ebda9dcb4241f5", size = 2026791, upload-time = "2026-04-13T09:04:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f1/6731c2d6caf03efe822101edb4783eb3f212f34b7b005a34f039f67e76e1/pydantic_core-2.46.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:ce2e38e27de73ff6a0312a9e3304c398577c418d90bbde97f0ba1ee3ab7ac39f", size = 2121259, upload-time = "2026-04-13T09:07:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/fd/ac34d4c92e739e37a040be9e7ea84d116afec5f983a7db856c27135fba77/pydantic_core-2.46.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:f0d34ba062396de0be7421e6e69c9a6821bf6dc73a0ab9959a48a5a6a1e24754", size = 1945798, upload-time = "2026-04-13T09:04:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a4/f413a522c4047c46b109be6805a3095d35e5a4882fd5b4fdc0909693dfc0/pydantic_core-2.46.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4c0a12147b4026dd68789fb9f22f1a8769e457f9562783c181880848bbd6412", size = 1986062, upload-time = "2026-04-13T09:05:57.177Z" }, + { url = "https://files.pythonhosted.org/packages/91/2e/9760025ea8b0f49903c0ceebdfc2d8ef839da872426f2b03cae9de036a7c/pydantic_core-2.46.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a99896d9db56df901ab4a63cd6a36348a569cff8e05f049db35f4016a817a3d9", size = 2145344, upload-time = "2026-04-13T09:03:56.924Z" }, + { url = "https://files.pythonhosted.org/packages/74/0c/106ed5cc50393d90523f09adcc50d05e42e748eb107dc06aea971137f02d/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:bc0e2fefe384152d7da85b5c2fe8ce2bf24752f68a58e3f3ea42e28a29dfdeb2", size = 2104968, upload-time = "2026-04-13T09:06:26.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b494cef3165e3413ee9bbbb5a9eedc9af0ea7b88d8638beef6c2061b110e/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a2ab0e785548be1b4362a62c4004f9217598b7ee465f1f420fc2123e2a5b5b02", size = 1940442, upload-time = "2026-04-13T09:06:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3e/a4d578c8216c443e26a1124f8c1e07c0654264ce5651143d3883d85ff140/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16d45aecb18b8cba1c68eeb17c2bb2d38627ceed04c5b30b882fc9134e01f187", size = 1999672, upload-time = "2026-04-13T09:04:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c1/9114560468685525a21770138382fd0cb849aaf351ff2c7b97f760d121e0/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5078f6c377b002428e984259ac327ef8902aacae6c14b7de740dd4869a491501", size = 2154533, upload-time = "2026-04-13T09:04:50.868Z" }, + { url = "https://files.pythonhosted.org/packages/09/ed/fbd8127e4a19c4fdbb2f4983cf72c7b3534086df640c813c5c0ec4218177/pydantic_core-2.46.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:be3e04979ba4d68183f247202c7f4f483f35df57690b3f875c06340a1579b47c", size = 2119951, upload-time = "2026-04-13T09:04:35.923Z" }, + { url = "https://files.pythonhosted.org/packages/ec/77/df8711ebb45910412f90d75198430fa1120f5618336b71fa00303601c5a4/pydantic_core-2.46.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1eae8d7d9b8c2a90b34d3d9014804dca534f7f40180197062634499412ea14e", size = 1953812, upload-time = "2026-04-13T09:05:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/12/fe/14b35df69112bd812d6818a395eeab22eeaa2befc6f85bc54ed648430186/pydantic_core-2.46.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a95a2773680dd4b6b999d4eccdd1b577fd71c31739fb4849f6ada47eabb9c56", size = 2139585, upload-time = "2026-04-13T09:06:46.94Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f0/4fea4c14ebbdeb87e5f6edd2620735fcbd384865f06707fe229c021ce041/pydantic_core-2.46.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25988c3159bb097e06abfdf7b21b1fcaf90f187c74ca6c7bb842c1f72ce74fa8", size = 2179154, upload-time = "2026-04-13T09:04:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/5c/36/6329aa79ba32b73560e6e453164fb29702b115fd3b2b650e796e1dc27862/pydantic_core-2.46.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:747d89bd691854c719a3381ba46b6124ef916ae85364c79e11db9c84995d8d03", size = 2182917, upload-time = "2026-04-13T09:07:24.483Z" }, + { url = "https://files.pythonhosted.org/packages/92/61/edbf7aea71052d410347846a2ea43394f74651bf6822b8fad8703ca00575/pydantic_core-2.46.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:909a7327b83ca93b372f7d48df0ebc7a975a5191eb0b6e024f503f4902c24124", size = 2327716, upload-time = "2026-04-13T09:06:31.681Z" }, + { url = "https://files.pythonhosted.org/packages/a4/11/aa5089b941e85294b1d5d526840b18f0d4464f842d43d8999ce50ef881c1/pydantic_core-2.46.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:2f7e6a3752378a69fadf3f5ee8bc5fa082f623703eec0f4e854b12c548322de0", size = 2365925, upload-time = "2026-04-13T09:05:38.338Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/e187b0ea247f71f2009d156df88b7d8449c52a38810c9a1bd55dd4871206/pydantic_core-2.46.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ef47ee0a3ac4c2bb25a083b3acafb171f65be4a0ac1e84edef79dd0016e25eaa", size = 2193856, upload-time = "2026-04-13T09:05:03.114Z" }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2067,18 +2075,18 @@ dependencies = [ { name = "cryptography" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969 }, + { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, ] [[package]] name = "pysocks" version = "1.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429 } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 }, + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, ] [[package]] @@ -2094,9 +2102,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -2108,9 +2116,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/69/4db1c30625af0621df8dbe73797b38b6d1b04e15d021dd5d26a6d297f78c/pytest_env-1.6.0.tar.gz", hash = "sha256:ac02d6fba16af54d61e311dd70a3c61024a4e966881ea844affc3c8f0bf207d3", size = 16163 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/69/4db1c30625af0621df8dbe73797b38b6d1b04e15d021dd5d26a6d297f78c/pytest_env-1.6.0.tar.gz", hash = "sha256:ac02d6fba16af54d61e311dd70a3c61024a4e966881ea844affc3c8f0bf207d3", size = 16163, upload-time = "2026-03-12T22:39:43.78Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/16/ad52f56b96d851a2bcfdc1e754c3531341885bd7177a128c13ff2ca72ab4/pytest_env-1.6.0-py3-none-any.whl", hash = "sha256:1e7f8a62215e5885835daaed694de8657c908505b964ec8097a7ce77b403d9a3", size = 10400 }, + { url = "https://files.pythonhosted.org/packages/27/16/ad52f56b96d851a2bcfdc1e754c3531341885bd7177a128c13ff2ca72ab4/pytest_env-1.6.0-py3-none-any.whl", hash = "sha256:1e7f8a62215e5885835daaed694de8657c908505b964ec8097a7ce77b403d9a3", size = 10400, upload-time = "2026-03-12T22:39:41.887Z" }, ] [[package]] @@ -2120,9 +2128,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] @@ -2133,91 +2141,91 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872 } +sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894 }, + { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, ] [[package]] name = "python-dotenv" version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "pytz" version = "2026.1.post1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088 } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489 }, + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, ] [[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 } +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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, + { 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]] @@ -2230,18 +2238,18 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120 } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947 }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] name = "retrying" version = "1.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411 } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859 }, + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, ] [[package]] @@ -2254,106 +2262,106 @@ dependencies = [ { name = "urllib3", extra = ["secure", "socks"] }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/0b/5ef429492faa5d951d8215918c7092de2dbc25cf895fdf46643260b22f92/selenium-4.2.0-py3-none-any.whl", hash = "sha256:ba5b2633f43cf6fe9d308fa4a6996e00a101ab9cb1aad6fd91ae1f3dbe57f56f", size = 983214 }, + { url = "https://files.pythonhosted.org/packages/c9/0b/5ef429492faa5d951d8215918c7092de2dbc25cf895fdf46643260b22f92/selenium-4.2.0-py3-none-any.whl", hash = "sha256:ba5b2633f43cf6fe9d308fa4a6996e00a101ab9cb1aad6fd91ae1f3dbe57f56f", size = 983214, upload-time = "2022-05-27T14:34:17.527Z" }, ] [[package]] name = "setuptools" version = "82.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316 } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223 }, + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "sortedcontainers" version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] name = "soupsieve" version = "2.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627 } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016 }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] [[package]] name = "tomli" version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] @@ -2369,9 +2377,9 @@ dependencies = [ { name = "sniffio" }, { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109 } +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294 }, + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, ] [[package]] @@ -2384,18 +2392,18 @@ dependencies = [ { name = "trio" }, { name = "wsproto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549 } +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221 }, + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] @@ -2405,36 +2413,36 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" version = "2026.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639 } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952 }, + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, ] [[package]] name = "unidecode" version = "1.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149 } +sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837 }, + { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" }, ] [[package]] name = "urllib3" version = "1.26.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225 }, + { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, ] [package.optional-dependencies] @@ -2453,9 +2461,9 @@ socks = [ name = "urllib3-secure-extra" version = "0.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/67/76b7c055ea787729bb9f839a84689ea2f88e217519d59ae547824431ec95/urllib3-secure-extra-0.1.0.tar.gz", hash = "sha256:ee9409cbfeb4b8609047be4c32fb4317870c602767e53fd8a41005ebe6a41dff", size = 986 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/67/76b7c055ea787729bb9f839a84689ea2f88e217519d59ae547824431ec95/urllib3-secure-extra-0.1.0.tar.gz", hash = "sha256:ee9409cbfeb4b8609047be4c32fb4317870c602767e53fd8a41005ebe6a41dff", size = 986, upload-time = "2022-08-01T14:51:36.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/cd/273b6978ace72ef1d3f35610206e44e4527d557500e3d7b39732f2b4dd3c/urllib3_secure_extra-0.1.0-py2.py3-none-any.whl", hash = "sha256:f7adcb108b4d12a4b26b99eb60e265d087f435052a76aefa396b6ee85e9a6ef9", size = 1444 }, + { url = "https://files.pythonhosted.org/packages/90/cd/273b6978ace72ef1d3f35610206e44e4527d557500e3d7b39732f2b4dd3c/urllib3_secure_extra-0.1.0-py2.py3-none-any.whl", hash = "sha256:f7adcb108b4d12a4b26b99eb60e265d087f435052a76aefa396b6ee85e9a6ef9", size = 1444, upload-time = "2022-08-01T14:51:33.853Z" }, ] [[package]] @@ -2469,18 +2477,18 @@ dependencies = [ { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742 } +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232 }, + { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, ] [[package]] name = "waitress" version = "3.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901 } +sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232 }, + { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, ] [[package]] @@ -2492,9 +2500,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/4f/6e44478908c5133f680378d687f14ecaa99feed2c535344fcf68d8d21500/webdriver_manager-4.0.2.tar.gz", hash = "sha256:efedf428f92fd6d5c924a0d054e6d1322dd77aab790e834ee767af392b35590f", size = 25940 } +sdist = { url = "https://files.pythonhosted.org/packages/24/4f/6e44478908c5133f680378d687f14ecaa99feed2c535344fcf68d8d21500/webdriver_manager-4.0.2.tar.gz", hash = "sha256:efedf428f92fd6d5c924a0d054e6d1322dd77aab790e834ee767af392b35590f", size = 25940, upload-time = "2024-07-25T08:13:49.331Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/b5/3bd0b038d80950ec13e6a2c8d03ed8354867dc60064b172f2f4ffac8afbe/webdriver_manager-4.0.2-py2.py3-none-any.whl", hash = "sha256:75908d92ecc45ff2b9953614459c633db8f9aa1ff30181cefe8696e312908129", size = 27778 }, + { url = "https://files.pythonhosted.org/packages/b5/b5/3bd0b038d80950ec13e6a2c8d03ed8354867dc60064b172f2f4ffac8afbe/webdriver_manager-4.0.2-py2.py3-none-any.whl", hash = "sha256:75908d92ecc45ff2b9953614459c633db8f9aa1ff30181cefe8696e312908129", size = 27778, upload-time = "2024-07-25T08:13:47.917Z" }, ] [[package]] @@ -2504,9 +2512,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852 } +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459 }, + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] [[package]] @@ -2516,25 +2524,25 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405 }, + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, ] [[package]] name = "xlsxwriter" version = "3.2.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940 } +sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315 }, + { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, ] [[package]] name = "zipp" version = "3.23.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965 } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378 }, + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, ] From e638ce45be05af1e14d2bfce349642984d4d2253 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 4 Jun 2026 21:38:50 +0200 Subject: [PATCH 074/151] feat(etapes): squelette de la page /etapes --- src/pages/etapes.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/pages/etapes.py diff --git a/src/pages/etapes.py b/src/pages/etapes.py new file mode 100644 index 0000000..c6737d4 --- /dev/null +++ b/src/pages/etapes.py @@ -0,0 +1,40 @@ +from dash import dcc, html, register_page + +from src.utils.seo import META_CONTENT + +NAME = "Quelles données pour quelles étapes et quels seuils ?" + +register_page( + __name__, + path="/etapes", + title=f"{NAME} | decp.info", + name="Étapes et données", + description=( + "À chaque étape d'un marché public (programmation, publicité, " + "attribution), quelles données sont publiées et à partir de quel " + "seuil : DECP, BOAMP, JOUE, journaux d'annonces légales, Approch." + ), + image_url=META_CONTENT["image_url"], +) + +layout = html.Div( + className="container", + children=[ + html.H2(NAME), + dcc.Markdown( + "Un marché public passe par plusieurs étapes. À chacune, des " + "données peuvent être publiées — selon le montant du marché et " + "des obligations réglementaires. Ce graphique situe les " + "principales publications de données par **étape** (de haut en " + "bas) et par **seuil** (de gauche à droite, en euros hors taxes)." + ), + # Le graphique sera inséré ici en Task 2 + dcc.Markdown( + "**À noter :** l'axe horizontal n'est pas linéaire — les seuils " + "sont espacés régulièrement pour rester lisibles. Les étapes " + "*Contrat* et *Paiement* n'ont aujourd'hui aucune donnée publiée " + "en open data.", + className="etapes-note", + ), + ], +) From 070a91fed164ee003e15b7f016987039c13387fd Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 4 Jun 2026 21:42:33 +0200 Subject: [PATCH 075/151] =?UTF-8?q?feat(etapes):=20graphique=20donn=C3=A9e?= =?UTF-8?q?s=20par=20=C3=A9tape=20et=20par=20seuil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/etapes.py | 138 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 1 deletion(-) diff --git a/src/pages/etapes.py b/src/pages/etapes.py index c6737d4..526e611 100644 --- a/src/pages/etapes.py +++ b/src/pages/etapes.py @@ -17,6 +17,141 @@ register_page( image_url=META_CONTENT["image_url"], ) + +def _lane(*bars): + """Une ligne d'étape : fond segmenté en 5 + barres positionnées.""" + return html.Div( + className="etapes-lane", + children=[ + html.Div( + className="etapes-segs", + children=[html.Div() for _ in range(5)], + ), + *bars, + ], + ) + + +def _bar(label, color, style): + base = {"backgroundColor": color} + base.update(style) + return html.Div(label, className="etapes-bar", style=base) + + +def build_chart(): + return html.Div( + className="etapes-chart-scroll", + children=html.Div( + className="etapes-chart", + children=[ + # En-tête : coin vide + 5 marqueurs de seuils + html.Div(className="etapes-corner"), + html.Div( + className="etapes-xhead", + children=[ + html.Div("0 €", className="etapes-xcell"), + html.Div( + [html.Strong("40 000 €"), "seuil DECP"], + className="etapes-xcell", + ), + html.Div( + [html.Strong("90 000 €"), "publicité"], + className="etapes-xcell", + ), + html.Div( + [html.Strong("140 k€ / 216 k€"), "seuils formalisés (UE)"], + className="etapes-xcell", + ), + html.Div( + [html.Strong("5,404 M€"), "travaux (UE)"], + className="etapes-xcell", + ), + ], + ), + # Programmation + html.Div("Programmation", className="etapes-stage"), + _lane( + _bar( + "Approch — sourcing / préinformation (non réglementaire)", + "#7c5cff", + {"left": "2%", "right": "2%"}, + ), + ), + # Publicité (appel d'offres) + html.Div( + ["Publicité ", html.Small("(appel d'offres)")], + className="etapes-stage", + ), + _lane( + _bar( + "Journaux d'annonces légales", + "#f79009", + {"left": "40%", "right": "40%", "top": "6px", "height": "20px"}, + ), + _bar( + "BOAMP", + "#1570ef", + {"left": "40%", "right": "2%", "top": "28px", "height": "20px"}, + ), + _bar( + "JOUE — avis de marché", + "#0e9384", + {"left": "60%", "right": "2%", "top": "6px", "height": "20px"}, + ), + ), + # Attribution + html.Div("Attribution", className="etapes-stage"), + _lane( + _bar( + "DECP — données essentielles", + "#12b76a", + {"left": "20%", "right": "2%", "top": "6px", "height": "20px"}, + ), + _bar( + "JOUE — avis d'attribution", + "#0e9384", + {"left": "60%", "right": "2%", "top": "28px", "height": "20px"}, + ), + ), + # Contrat (vide) + html.Div("Contrat", className="etapes-stage"), + html.Div( + "— aucune donnée publiée aujourd'hui —", + className="etapes-lane etapes-empty", + ), + # Paiement (vide) + html.Div("Paiement", className="etapes-stage"), + html.Div( + "— aucune donnée publiée aujourd'hui —", + className="etapes-lane etapes-empty", + ), + ], + ), + ) + + +def build_legend(): + items = [ + ("Approch", "#7c5cff"), + ("Journaux d'annonces légales", "#f79009"), + ("BOAMP", "#1570ef"), + ("JOUE", "#0e9384"), + ("DECP", "#12b76a"), + ] + return html.Div( + className="etapes-legend", + children=[ + html.Span( + [ + html.I(style={"backgroundColor": color}), + label, + ] + ) + for label, color in items + ], + ) + + layout = html.Div( className="container", children=[ @@ -28,7 +163,8 @@ layout = html.Div( "principales publications de données par **étape** (de haut en " "bas) et par **seuil** (de gauche à droite, en euros hors taxes)." ), - # Le graphique sera inséré ici en Task 2 + build_chart(), + build_legend(), dcc.Markdown( "**À noter :** l'axe horizontal n'est pas linéaire — les seuils " "sont espacés régulièrement pour rester lisibles. Les étapes " From 4c60fed0c217601df1c96510c7ccfc91be7d09c6 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 4 Jun 2026 21:45:25 +0200 Subject: [PATCH 076/151] =?UTF-8?q?feat(etapes):=20vue=20mobile=20liste=20?= =?UTF-8?q?par=20=C3=A9tape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/pages/etapes.py | 69 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/pages/etapes.py b/src/pages/etapes.py index 526e611..87ce041 100644 --- a/src/pages/etapes.py +++ b/src/pages/etapes.py @@ -130,6 +130,74 @@ def build_chart(): ) +# Données par étape, partagées par la vue mobile. +# Chaque item : (libellé, couleur, plage de seuils en texte). +STAGES_MOBILE = [ + ( + "Programmation", + [ + ("Approch", "#7c5cff", "tous montants — publication non réglementaire"), + ], + ), + ( + "Publicité (appel d'offres)", + [ + ( + "Journaux d'annonces légales", + "#f79009", + "de 90 000 € au seuil formalisé", + ), + ("BOAMP", "#1570ef", "à partir de 90 000 €"), + ( + "JOUE — avis de marché", + "#0e9384", + "à partir des seuils formalisés (140 k€ / 216 k€)", + ), + ], + ), + ( + "Attribution", + [ + ("DECP — données essentielles", "#12b76a", "à partir de 40 000 €"), + ("JOUE — avis d'attribution", "#0e9384", "à partir des seuils formalisés"), + ], + ), + ("Contrat", []), + ("Paiement", []), +] + + +def build_mobile(): + blocks = [] + for stage, items in STAGES_MOBILE: + if items: + children = [ + html.Div( + [ + html.I(style={"backgroundColor": color}), + html.Span(label, className="etapes-m-label"), + html.Span(seuil, className="etapes-m-seuil"), + ], + className="etapes-m-item", + ) + for label, color, seuil in items + ] + else: + children = [ + html.Div( + "aucune donnée publiée aujourd'hui", + className="etapes-m-item etapes-m-empty", + ) + ] + blocks.append( + html.Div( + [html.H4(stage, className="etapes-m-stage"), *children], + className="etapes-m-block", + ) + ) + return html.Div(blocks, className="etapes-mobile") + + def build_legend(): items = [ ("Approch", "#7c5cff"), @@ -164,6 +232,7 @@ layout = html.Div( "bas) et par **seuil** (de gauche à droite, en euros hors taxes)." ), build_chart(), + build_mobile(), build_legend(), dcc.Markdown( "**À noter :** l'axe horizontal n'est pas linéaire — les seuils " From b8db0cddf88645d7df72e577232876bf039bd824 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 4 Jun 2026 21:48:28 +0200 Subject: [PATCH 077/151] =?UTF-8?q?feat(etapes):=20styles=20du=20graphique?= =?UTF-8?q?=20=C3=A9tapes/seuils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/style.css | 197 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index dcb90d6..b0d9f5f 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -546,3 +546,200 @@ input[type="number"]::-webkit-inner-spin-button { input[type="number"] { -moz-appearance: textfield; } + +/* ===== Page /etapes : graphique données par étape et par seuil ===== */ + +.etapes-chart-scroll { + overflow-x: auto; + margin: 1rem 0; +} + +.etapes-chart { + min-width: 720px; + background: #fff; + border: 1px solid #d0d5dd; + border-radius: 8px; + overflow: hidden; + font-size: 13px; + display: grid; + grid-template-columns: 150px repeat(5, 1fr); +} + +.etapes-corner { + border-bottom: 2px solid #344054; +} + +.etapes-xhead { + grid-column: 2 / -1; + display: grid; + grid-template-columns: repeat(5, 1fr); + border-bottom: 2px solid #344054; +} + +.etapes-xcell { + text-align: center; + padding: 6px 2px; + font-size: 11px; + color: #475467; + border-left: 1px dashed #d0d5dd; +} + +.etapes-xcell strong { + display: block; + color: #101828; + font-size: 12px; +} + +.etapes-stage { + padding: 14px 10px; + font-weight: 600; + color: #101828; + border-bottom: 1px solid #eaecf0; + display: flex; + align-items: center; +} + +.etapes-stage small { + font-weight: 400; + color: #667085; +} + +.etapes-lane { + grid-column: 2 / -1; + position: relative; + border-bottom: 1px solid #eaecf0; + min-height: 52px; +} + +.etapes-segs { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(5, 1fr); +} + +.etapes-segs > div { + border-left: 1px dashed #eaecf0; +} + +.etapes-bar { + position: absolute; + top: 9px; + height: 32px; + border-radius: 6px; + color: #fff; + font-size: 11px; + font-weight: 600; + display: flex; + align-items: center; + padding: 0 10px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); + white-space: nowrap; + overflow: hidden; +} + +.etapes-empty { + color: #98a2b3; + font-style: italic; + padding: 14px; + display: flex; + align-items: center; +} + +.etapes-legend { + margin-top: 14px; + display: flex; + gap: 16px; + flex-wrap: wrap; + font-size: 12px; +} + +.etapes-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.etapes-legend i { + width: 14px; + height: 14px; + border-radius: 3px; + display: inline-block; +} + +.etapes-note { + margin-top: 8px; + color: #667085; + font-size: 13px; +} + +/* --- Vue mobile (liste par étape) : masquée par défaut --- */ + +.etapes-mobile { + display: none; + margin: 1rem 0; +} + +.etapes-m-block { + border: 1px solid #d0d5dd; + border-radius: 8px; + margin-bottom: 12px; + overflow: hidden; +} + +.etapes-m-stage { + margin: 0; + padding: 10px 12px; + background: #f9fafb; + border-bottom: 1px solid #eaecf0; + font-size: 15px; + color: #101828; +} + +.etapes-m-item { + display: flex; + align-items: baseline; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid #f2f4f7; + font-size: 13px; +} + +.etapes-m-item:last-child { + border-bottom: none; +} + +.etapes-m-item i { + width: 12px; + height: 12px; + border-radius: 3px; + flex: 0 0 auto; + position: relative; + top: 2px; +} + +.etapes-m-label { + font-weight: 600; + color: #101828; +} + +.etapes-m-seuil { + color: #667085; +} + +.etapes-m-empty { + color: #98a2b3; + font-style: italic; +} + +/* --- Bascule desktop / mobile au point de rupture 768 px --- */ + +@media (max-width: 768px) { + .etapes-chart-scroll, + .etapes-legend { + display: none; + } + .etapes-mobile { + display: block; + } +} From c9a97fe9d2d83c7b6e395bea1071562cfb39538a Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 4 Jun 2026 21:51:30 +0200 Subject: [PATCH 078/151] =?UTF-8?q?feat(etapes):=20r=C3=A9f=C3=A9rencement?= =?UTF-8?q?=20de=20/etapes=20dans=20le=20sitemap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app.py b/src/app.py index ad75f04..9ae0e0f 100644 --- a/src/app.py +++ b/src/app.py @@ -75,6 +75,7 @@ def sitemap(): "/observatoire", "/tableau", "/a-propos", + "/etapes", ] xml = '\n' xml += '\n' From ab7471767971e8557a4ee0355b3e04f5253deaee Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 4 Jun 2026 22:04:31 +0200 Subject: [PATCH 079/151] =?UTF-8?q?feat(etapes):=20suppression=20l=C3=A9ge?= =?UTF-8?q?nde,=20JAL=20=C3=A0=20la=20place=20de=20Journaux=20d'annonces?= =?UTF-8?q?=20l=C3=A9gales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/style.css | 24 +----------------------- src/pages/etapes.py | 33 +++------------------------------ 2 files changed, 4 insertions(+), 53 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index b0d9f5f..cd7ed78 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -646,27 +646,6 @@ input[type="number"] { align-items: center; } -.etapes-legend { - margin-top: 14px; - display: flex; - gap: 16px; - flex-wrap: wrap; - font-size: 12px; -} - -.etapes-legend span { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.etapes-legend i { - width: 14px; - height: 14px; - border-radius: 3px; - display: inline-block; -} - .etapes-note { margin-top: 8px; color: #667085; @@ -735,8 +714,7 @@ input[type="number"] { /* --- Bascule desktop / mobile au point de rupture 768 px --- */ @media (max-width: 768px) { - .etapes-chart-scroll, - .etapes-legend { + .etapes-chart-scroll { display: none; } .etapes-mobile { diff --git a/src/pages/etapes.py b/src/pages/etapes.py index 87ce041..efdc87c 100644 --- a/src/pages/etapes.py +++ b/src/pages/etapes.py @@ -79,12 +79,12 @@ def build_chart(): ), # Publicité (appel d'offres) html.Div( - ["Publicité ", html.Small("(appel d'offres)")], + ["Publicité"], className="etapes-stage", ), _lane( _bar( - "Journaux d'annonces légales", + "JAL", "#f79009", {"left": "40%", "right": "40%", "top": "6px", "height": "20px"}, ), @@ -142,11 +142,7 @@ STAGES_MOBILE = [ ( "Publicité (appel d'offres)", [ - ( - "Journaux d'annonces légales", - "#f79009", - "de 90 000 € au seuil formalisé", - ), + ("JAL", "#f79009", "de 90 000 € au seuil formalisé"), ("BOAMP", "#1570ef", "à partir de 90 000 €"), ( "JOUE — avis de marché", @@ -198,28 +194,6 @@ def build_mobile(): return html.Div(blocks, className="etapes-mobile") -def build_legend(): - items = [ - ("Approch", "#7c5cff"), - ("Journaux d'annonces légales", "#f79009"), - ("BOAMP", "#1570ef"), - ("JOUE", "#0e9384"), - ("DECP", "#12b76a"), - ] - return html.Div( - className="etapes-legend", - children=[ - html.Span( - [ - html.I(style={"backgroundColor": color}), - label, - ] - ) - for label, color in items - ], - ) - - layout = html.Div( className="container", children=[ @@ -233,7 +207,6 @@ layout = html.Div( ), build_chart(), build_mobile(), - build_legend(), dcc.Markdown( "**À noter :** l'axe horizontal n'est pas linéaire — les seuils " "sont espacés régulièrement pour rester lisibles. Les étapes " From 4d3e8ac344896d2b107c7187d8837fae48dcc373 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 4 Jun 2026 22:15:10 +0200 Subject: [PATCH 080/151] feat(etapes): fiches cliquables sur les barres et vue mobile --- src/assets/css/style.css | 44 ++++++++++++++++- src/pages/etapes.py | 103 ++++++++++++++++++++++++++++++++++----- 2 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index cd7ed78..3288369 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -636,6 +636,13 @@ input[type="number"] { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); white-space: nowrap; overflow: hidden; + cursor: pointer; + transition: filter 0.15s, box-shadow 0.15s; +} + +.etapes-bar:hover { + filter: brightness(1.12); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22); } .etapes-empty { @@ -666,15 +673,36 @@ input[type="number"] { overflow: hidden; } +.etapes-m-header { + display: flex; + align-items: center; + justify-content: space-between; + background: #f9fafb; + border-bottom: 1px solid #eaecf0; +} + .etapes-m-stage { margin: 0; padding: 10px 12px; - background: #f9fafb; - border-bottom: 1px solid #eaecf0; font-size: 15px; color: #101828; } +.etapes-m-link { + background: none; + border: none; + color: #1570ef; + font-size: 12px; + font-weight: 600; + cursor: pointer; + padding: 0 12px; + white-space: nowrap; +} + +.etapes-m-link:hover { + text-decoration: underline; +} + .etapes-m-item { display: flex; align-items: baseline; @@ -711,6 +739,18 @@ input[type="number"] { font-style: italic; } +.etapes-detail { + margin: 1rem 0; + padding: 1rem 1.25rem; + border: 1px solid #d0d5dd; + border-radius: 8px; + background: #f9fafb; +} + +.etapes-detail:empty { + display: none; +} + /* --- Bascule desktop / mobile au point de rupture 768 px --- */ @media (max-width: 768px) { diff --git a/src/pages/etapes.py b/src/pages/etapes.py index efdc87c..3f2f4a5 100644 --- a/src/pages/etapes.py +++ b/src/pages/etapes.py @@ -1,4 +1,4 @@ -from dash import dcc, html, register_page +from dash import Input, Output, State, callback, ctx, dcc, html, register_page from src.utils.seo import META_CONTENT @@ -17,6 +17,41 @@ register_page( image_url=META_CONTENT["image_url"], ) +# Contenu des fiches — à rédiger en Markdown. +# Clés barres : "bar-approch", "bar-jal", "bar-boamp", "bar-joue-marche", +# "bar-decp", "bar-joue-attribution" +# Clés étapes (mobile) : "stage-programmation", "stage-publicite", +# "stage-attribution", "stage-contrat", "stage-paiement" +ALL_CONTENT: dict[str, str | None] = { + "bar-approch": None, + "bar-jal": None, + "bar-boamp": None, + "bar-joue-marche": None, + "bar-decp": None, + "bar-joue-attribution": None, + "stage-programmation": None, + "stage-publicite": None, + "stage-attribution": None, + "stage-contrat": None, + "stage-paiement": None, +} + +_BAR_IDS = [ + "bar-approch", + "bar-jal", + "bar-boamp", + "bar-joue-marche", + "bar-decp", + "bar-joue-attribution", +] +_STAGE_IDS = [ + "stage-programmation", + "stage-publicite", + "stage-attribution", + "stage-contrat", + "stage-paiement", +] + def _lane(*bars): """Une ligne d'étape : fond segmenté en 5 + barres positionnées.""" @@ -32,10 +67,14 @@ def _lane(*bars): ) -def _bar(label, color, style): +def _bar(label, color, style, bar_id=None): base = {"backgroundColor": color} base.update(style) - return html.Div(label, className="etapes-bar", style=base) + props = {"className": "etapes-bar", "style": base} + if bar_id is not None: + props["id"] = bar_id + props["n_clicks"] = 0 + return html.Div(label, **props) def build_chart(): @@ -75,28 +114,29 @@ def build_chart(): "Approch — sourcing / préinformation (non réglementaire)", "#7c5cff", {"left": "2%", "right": "2%"}, + bar_id="bar-approch", ), ), # Publicité (appel d'offres) - html.Div( - ["Publicité"], - className="etapes-stage", - ), + html.Div(["Publicité"], className="etapes-stage"), _lane( _bar( "JAL", "#f79009", {"left": "40%", "right": "40%", "top": "6px", "height": "20px"}, + bar_id="bar-jal", ), _bar( "BOAMP", "#1570ef", {"left": "40%", "right": "2%", "top": "28px", "height": "20px"}, + bar_id="bar-boamp", ), _bar( "JOUE — avis de marché", "#0e9384", {"left": "60%", "right": "2%", "top": "6px", "height": "20px"}, + bar_id="bar-joue-marche", ), ), # Attribution @@ -106,11 +146,13 @@ def build_chart(): "DECP — données essentielles", "#12b76a", {"left": "20%", "right": "2%", "top": "6px", "height": "20px"}, + bar_id="bar-decp", ), _bar( "JOUE — avis d'attribution", "#0e9384", {"left": "60%", "right": "2%", "top": "28px", "height": "20px"}, + bar_id="bar-joue-attribution", ), ), # Contrat (vide) @@ -131,16 +173,18 @@ def build_chart(): # Données par étape, partagées par la vue mobile. -# Chaque item : (libellé, couleur, plage de seuils en texte). +# Chaque tuple : (libellé étape, id CSS, [(libellé, couleur, plage seuils)]). STAGES_MOBILE = [ ( "Programmation", + "stage-programmation", [ ("Approch", "#7c5cff", "tous montants — publication non réglementaire"), ], ), ( "Publicité (appel d'offres)", + "stage-publicite", [ ("JAL", "#f79009", "de 90 000 € au seuil formalisé"), ("BOAMP", "#1570ef", "à partir de 90 000 €"), @@ -153,19 +197,20 @@ STAGES_MOBILE = [ ), ( "Attribution", + "stage-attribution", [ ("DECP — données essentielles", "#12b76a", "à partir de 40 000 €"), ("JOUE — avis d'attribution", "#0e9384", "à partir des seuils formalisés"), ], ), - ("Contrat", []), - ("Paiement", []), + ("Contrat", "stage-contrat", []), + ("Paiement", "stage-paiement", []), ] def build_mobile(): blocks = [] - for stage, items in STAGES_MOBILE: + for stage, stage_id, items in STAGES_MOBILE: if items: children = [ html.Div( @@ -187,7 +232,21 @@ def build_mobile(): ] blocks.append( html.Div( - [html.H4(stage, className="etapes-m-stage"), *children], + [ + html.Div( + [ + html.H4(stage, className="etapes-m-stage"), + html.Button( + "Voir fiche →", + id=stage_id, + n_clicks=0, + className="etapes-m-link", + ), + ], + className="etapes-m-header", + ), + *children, + ], className="etapes-m-block", ) ) @@ -207,6 +266,8 @@ layout = html.Div( ), build_chart(), build_mobile(), + dcc.Store(id="etapes-selected", data=None), + html.Div(id="etapes-detail", className="etapes-detail"), dcc.Markdown( "**À noter :** l'axe horizontal n'est pas linéaire — les seuils " "sont espacés régulièrement pour rester lisibles. Les étapes " @@ -216,3 +277,21 @@ layout = html.Div( ), ], ) + + +@callback( + Output("etapes-detail", "children"), + Output("etapes-selected", "data"), + [Input(id_, "n_clicks") for id_ in _BAR_IDS + _STAGE_IDS], + State("etapes-selected", "data"), + prevent_initial_call=True, +) +def _show_detail(*args): + current = args[-1] + triggered = ctx.triggered_id + if triggered == current: + return None, None + content = ALL_CONTENT.get(triggered) + if content is None: + return dcc.Markdown("*Fiche en cours de rédaction.*"), triggered + return dcc.Markdown(content), triggered From ed32b0f66b12abc34595b6b2651ac53d678dd179 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sun, 7 Jun 2026 18:56:16 +0200 Subject: [PATCH 081/151] =?UTF-8?q?R=C3=A9duction=20des=20petites=20erreur?= =?UTF-8?q?s=20qui=20polluent=20les=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/arbre/liste_marches_org.py | 10 +++++++--- src/pages/etapes.py | 4 ++-- src/pages/marche.py | 3 ++- src/utils/data.py | 18 ++++++++++-------- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/pages/arbre/liste_marches_org.py b/src/pages/arbre/liste_marches_org.py index 26cba1c..a5d9fd9 100644 --- a/src/pages/arbre/liste_marches_org.py +++ b/src/pages/arbre/liste_marches_org.py @@ -2,6 +2,7 @@ import polars as pl from dash import Input, Output, callback, dcc, html, register_page from src.db import get_cursor +from src.utils import logger from src.utils.data import DF_ACHETEURS, DF_TITULAIRES NAME = "Liste des marchés publics" @@ -27,9 +28,12 @@ def make_org_nom_verbe(org_type, org_id) -> tuple: def get_title(code, org_type, org_id): - org_nom, verbe = make_org_nom_verbe(org_type, org_id) - - return f"Marchés publics {verbe} par {org_nom} | decp.info" + if org_type: + org_nom, verbe = make_org_nom_verbe(org_type, org_id) + return f"Marchés publics {verbe} par {org_nom} | decp.info" + else: + logger.warning(f"Pas de org_type pour org_id: {org_id}") + return "Marchés publics | decp.info" def get_description(code, org_type, org_id): diff --git a/src/pages/etapes.py b/src/pages/etapes.py index 3f2f4a5..9af912e 100644 --- a/src/pages/etapes.py +++ b/src/pages/etapes.py @@ -2,7 +2,7 @@ from dash import Input, Output, State, callback, ctx, dcc, html, register_page from src.utils.seo import META_CONTENT -NAME = "Quelles données pour quelles étapes et quels seuils ?" +NAME = "Quelles données pour quelles étapes et quels seuils dans les marchés publics ?" register_page( __name__, @@ -293,5 +293,5 @@ def _show_detail(*args): return None, None content = ALL_CONTENT.get(triggered) if content is None: - return dcc.Markdown("*Fiche en cours de rédaction.*"), triggered + return dcc.Markdown(f"*Fiche en cours de rédaction.* {triggered}"), triggered return dcc.Markdown(content), triggered diff --git a/src/pages/marche.py b/src/pages/marche.py index 9587b56..49b42be 100644 --- a/src/pages/marche.py +++ b/src/pages/marche.py @@ -109,7 +109,7 @@ def update_marche_info(marche, titulaires): column_object = DATA_SCHEMA.get(col) column_name = column_object.get("title") if column_object else col - if col in marche: + if marche and col in marche: if col == "acheteur_nom": value = html.A( href=f"/acheteurs/{marche['acheteur_id']}", @@ -134,6 +134,7 @@ def update_marche_info(marche, titulaires): "considerationsSociales", "considerationsEnvironnementales", ] + and col in marche and "," in marche[col] ): col_values = marche[col].split(", ") diff --git a/src/utils/data.py b/src/utils/data.py index 0c792a5..c5304d5 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -53,14 +53,16 @@ def get_departements_geojson() -> dict: return geojson -def get_departement_region(code_postal): - if code_postal > "97000": - code_departement = code_postal[:3] - else: - code_departement = code_postal[:2] - nom_departement = DEPARTEMENTS[code_departement]["departement"] - nom_region = DEPARTEMENTS[code_departement]["region"] - return code_departement, nom_departement, nom_region +def get_departement_region(code_postal: str | None): + if code_postal: + if code_postal > "97000": + code_departement = code_postal[:3] + else: + code_departement = code_postal[:2] + nom_departement = DEPARTEMENTS[code_departement]["departement"] + nom_region = DEPARTEMENTS[code_departement]["region"] + return code_departement, nom_departement, nom_region + return "", "", "" def get_data_schema() -> dict: From 5341ca002e75f446aa42423841a98d0305c57b25 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 9 Jun 2026 11:28:16 +0200 Subject: [PATCH 082/151] Bump changelog 2.7.9 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f96906..197a46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +##### 2.7.9 (9 juin 2026) + +- Ajout d'une vue "étapes" (elle sera mieux intégrée dans le site à l'avenir) +- Correction de petites erreurs qui polluent les logs + ##### 2.7.8 (18 mai 2026) - Récupération du schéma de données plus robuste, ne pas dépendre de data.gouv.fr diff --git a/pyproject.toml b/pyproject.toml index f61635b..d46bdef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "decp.info" description = "Interface d'exploration et d'analyse des marchés publics français." -version = "2.7.8" +version = "2.7.9" requires-python = ">= 3.10" authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }] dependencies = [ From 8a853b23849d9e1df72b8cc096af4b26587be52c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 11:01:41 +0200 Subject: [PATCH 083/151] =?UTF-8?q?docs:=20spec=20bootstrap=20r=C3=A9silie?= =?UTF-8?q?nt=20donn=C3=A9es=20et=20sch=C3=A9ma=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...otstrap-resilient-donnees-schema-design.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md diff --git a/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md b/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md new file mode 100644 index 0000000..71da219 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md @@ -0,0 +1,201 @@ +# Bootstrap résilient des données et du schéma + +**Date :** 2026-06-12 +**Branche :** `feature/78_api` +**Statut :** design approuvé, à implémenter + +## Problème + +L'API et l'appli Web Dash partagent le même process Python (`gunicorn app:server`). +L'API sera consommée par des clients en production. Or decp.info tombe « de temps +en temps », et comme tout est dans le même process, une chute du Web emporte l'API. + +**Diagnostic (clé).** Les chutes ne sont **pas** des crashs runtime aléatoires +pendant l'ingestion. Ce sont des **échecs de bootstrap au déploiement** : + +- env oubliée lors d'un déploiement (ex. `DATA_FILE_PARQUET_PATH` vide) ; +- `DATA_FILE_PARQUET_PATH` (désormais une URL data.gouv.fr) injoignable ou + pointant vers un parquet absent/invalide à cause d'un souci dans + `decp-processing` ; +- `DATA_SCHEMA_PATH` (URL data.gouv.fr) qui renvoie une erreur. + +Le process démarre sur des ressources manquantes/invalides, lève une exception +**au moment de l'import** (`src/db.py` et `src/utils/data.py` font leur bootstrap +au niveau module), et meurt au boot — API comprise. + +## Pourquoi pas « séparer les process » ? + +La séparation API / Web protège contre la **contagion runtime** (un callback Dash +qui tue le worker). Elle ne protège **pas** contre le mode d'échec réel : si les +deux process partagent les mêmes ressources de bootstrap (parquet, schéma, env), +ils échouent **tous les deux** au démarrage, de manière identique. + +Le levier réel est donc le **durcissement du bootstrap avec fallback +« last-known-good »** : garantir présence + validité des ressources, et sinon +repartir sur les dernières ressources fonctionnelles. + +La séparation des process reste **hors périmètre** de ce spec. La couche données +(`src/db.py`) est déjà process-agnostique et sans dépendance à Dash, donc la +séparation restera bon marché à dégainer plus tard _si_ un vrai crash runtime +touche l'API. On ne paie pas cette complexité tant qu'on n'en a pas la preuve. + +## État actuel du code (post-merge `main`) + +### `src/db.py` + +- Bootstrap au niveau module : `DB_PATH = _ensure_database()` puis ouverture d'une + connexion DuckDB read-only partagée et lecture du `schema`. +- `build_database()` écrit dans un fichier temporaire puis `os.replace()` atomique : + un build qui échoue en cours de route **laisse l'ancien DuckDB intact**. ✅ +- **Faille :** `should_rebuild()` appelle `get_last_modified(parquet_path)` qui + fait un `httpx.head(...).headers["last-modified"]` **sans aucune gestion + d'erreur** (`src/utils/__init__.py:12`). URL injoignable, lente, ou sans en-tête + `last-modified` ⇒ exception ⇒ remonte jusqu'à l'import ⇒ **mort au démarrage + alors qu'un DuckDB valide existe sur disque**. +- **Faille :** `_load_source_frame()` fait `assert os.path.exists(parquet_path)` + (non-http) et `scan_parquet` (http) — les deux peuvent lever et ne sont pas + rattrapés au niveau de `_ensure_database()`. + +### `src/utils/data.py` — `get_data_schema()` + +- Tente l'URL, attrape **seulement 4 erreurs httpx** (`ReadTimeout`, `ReadError`, + `ConnectError`, `ConnectTimeout`), sinon fallback sur `DATA_SCHEMA_LOCAL`. +- **Faille :** pas de `raise_for_status()`. Quand data.gouv renvoie une **erreur + HTTP** (le cas cité par l'utilisateur), `.json()` ne contient pas `"fields"` ⇒ + `KeyError` ligne 92, **sans fallback local**. +- **Faille :** un payload distant valide JSON mais malformé (sans `"fields"`) + plante aussi sans fallback. +- **Faille :** si les deux sources échouent, `original_schema["fields"]` ⇒ + `KeyError` opaque au lieu d'une erreur claire. + +## Décisions + +1. **Schéma** : URL primaire, fallback local. (confirmé) +2. **DuckDB** : réutiliser le dernier DuckDB construit en cas d'échec. (confirmé) +3. **Last-known-good réel du schéma** : après un fetch distant réussi, persister + le schéma localement pour que le fallback soit toujours le _dernier schéma + distant fonctionnel_. (confirmé) + +### Nuance sur le chemin de persistance du schéma + +L'utilisateur a demandé « écrire dans `DATA_SCHEMA_LOCAL` ». Mais en dev, +`DATA_SCHEMA_LOCAL = ../decp-processing/dist/schema.json` — un fichier d'un **autre +repo**. L'écraser au boot salirait ce repo. + +**Choix retenu (à confirmer en relecture) :** introduire un **chemin de cache +dédié que l'app possède**, distinct du fichier statique de secours. + +- `DATA_SCHEMA_PATH` (URL) — source primaire. +- `DATA_SCHEMA_CACHE` (nouveau, ex. défaut `./schema.cache.json`) — écrit après + chaque fetch distant réussi ; lu en fallback n°1 (dernier distant fonctionnel). +- `DATA_SCHEMA_LOCAL` — graine statique de secours (fichier `decp-processing`), + **lue mais jamais écrite**. + +Chaîne de résolution : `URL → cache → local statique → RuntimeError`. + +## Design + +### Invariant 1 — Bootstrap DuckDB (`src/db.py`) + +> Le process démarre tant qu'un DuckDB exploitable existe, quel que soit l'état de +> la source distante/parquet. Échec dur **seulement** s'il n'existe aucune base +> (cold start). + +Garde-fou unique dans `_ensure_database()` : + +```python +def _ensure_database() -> Path: + db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb")) + parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "") + lock_path = db_path.with_suffix(".duckdb.lock") + db_exists = db_path.exists() + with open(lock_path, "w") as lock_fd: + fcntl.flock(lock_fd, fcntl.LOCK_EX) + try: + if should_rebuild(db_path, parquet_path): + build_database(db_path) + except Exception as e: + if db_exists: + logger.error( + f"Bootstrap données KO ({e}). " + f"Réutilisation du DuckDB existant : {db_path}" + ) + else: + logger.critical("Aucune base DuckDB et reconstruction impossible.") + raise + return db_path +``` + +- `should_rebuild()` qui lève (via `get_last_modified()`) est désormais rattrapé : + base existante ⇒ on la réutilise. +- `build_database()` qui lève sur parquet invalide : base existante intacte + (atomicité) ⇒ on la réutilise. +- Le mode `DEVELOPMENT` sort de `should_rebuild()` **avant** tout appel réseau + (court-circuit `if dev and not force: return False`) ⇒ dev inchangé. + +### Invariant 2 — Schéma (`src/utils/data.py`) + +> Un schéma valide non-vide est toujours retourné si une source (distant, cache, +> ou local statique) en fournit un. Échec dur seulement si aucune. + +```python +def get_data_schema() -> dict: + raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH")) # dict valide | None + if raw is not None: + _persist_schema_cache(raw, os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")) + else: + raw = _load_schema_file(os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")) + if raw is None: + raw = _load_schema_file(os.getenv("DATA_SCHEMA_LOCAL", "")) + if raw is None: + raise RuntimeError("Aucun schéma disponible (distant, cache ni local).") + return OrderedDict((c["name"], c) for c in raw["fields"]) +``` + +Helpers : + +- `_fetch_remote_schema(url) -> dict | None` : `get(...).raise_for_status().json()`, + **valide `"fields" in data`**, attrape large (`httpx.HTTPError`, + `json.JSONDecodeError`, `KeyError`), log l'erreur, renvoie `None` sur tout échec. +- `_load_schema_file(path) -> dict | None` : lit le fichier s'il existe, parse, + valide `"fields"`, renvoie `None` sinon. +- `_persist_schema_cache(data, path)` : écriture atomique (tmp + `os.replace`) ; + un échec d'écriture est loggé mais **non bloquant** (le schéma en mémoire reste + valide). + +## Tests (TDD) + +Couvrir chaque branche de fallback. Sans dépendre du réseau réel. + +**Schéma (`get_data_schema` / helpers) :** + +1. URL OK ⇒ schéma distant retourné **et** cache écrit. +2. URL renvoie une erreur HTTP (mock 500) ⇒ fallback cache. +3. URL renvoie un JSON malformé (sans `"fields"`) ⇒ fallback cache. +4. URL KO + cache présent ⇒ schéma du cache. +5. URL KO + cache absent + local statique présent ⇒ schéma local. +6. Toutes sources KO ⇒ `RuntimeError` claire. +7. Échec d'écriture du cache ⇒ schéma quand même retourné (non bloquant). + +**Bootstrap DuckDB (`_ensure_database`) :** 8. `should_rebuild` lève + DuckDB existant ⇒ réutilisé, pas d'exception. 9. `build_database` lève + DuckDB existant ⇒ réutilisé, pas d'exception. 10. Échec + **aucun** DuckDB (cold start) ⇒ ré-lève. 11. Cas nominal : rebuild nécessaire et possible ⇒ build effectué. + +Mocker `get_last_modified` / `build_database` / `httpx.get` ; utiliser des fichiers +DuckDB et schéma temporaires (`tmp_path`). + +## Hors périmètre + +- Séparation des process API / Web (reportée — voir plus haut). +- Surveillance / alerting externe (les logs `error`/`critical` suffisent pour ce lot). +- Validation fine du contenu du parquet au-delà de « lisible par Polars/DuckDB ». + +## Variables d'environnement + +| Variable | Rôle | Changement | +| ------------------------ | --------------------------------------- | -------------------- | +| `DATA_FILE_PARQUET_PATH` | Source parquet (URL ou chemin) | inchangé | +| `DATA_SCHEMA_PATH` | URL schéma (primaire) | inchangé | +| `DATA_SCHEMA_LOCAL` | Fichier schéma de secours statique | **lu, jamais écrit** | +| `DATA_SCHEMA_CACHE` | Cache last-known-good du schéma distant | **nouveau** | +| `DUCKDB_PATH` | Fichier DuckDB | inchangé | + +Mettre à jour `.template.env` avec `DATA_SCHEMA_CACHE`. From 0ebbfcd872398b5d118a9022f02fc19f80f39e43 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 11:10:13 +0200 Subject: [PATCH 084/151] =?UTF-8?q?docs:=20spec=20sch=C3=A9ma=20en=20cache?= =?UTF-8?q?=20seul=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...otstrap-resilient-donnees-schema-design.md | 88 ++++++++++++------- 1 file changed, 55 insertions(+), 33 deletions(-) diff --git a/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md b/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md index 71da219..6b0f74e 100644 --- a/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md +++ b/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md @@ -70,28 +70,43 @@ touche l'API. On ne paie pas cette complexité tant qu'on n'en a pas la preuve. ## Décisions -1. **Schéma** : URL primaire, fallback local. (confirmé) +1. **Schéma** : URL primaire, **cache seul** en fallback (on supprime + `DATA_SCHEMA_LOCAL`). (confirmé) 2. **DuckDB** : réutiliser le dernier DuckDB construit en cas d'échec. (confirmé) 3. **Last-known-good réel du schéma** : après un fetch distant réussi, persister - le schéma localement pour que le fallback soit toujours le _dernier schéma - distant fonctionnel_. (confirmé) + le schéma dans un cache local pour que le fallback soit toujours le _dernier + schéma distant fonctionnel_. (confirmé) -### Nuance sur le chemin de persistance du schéma +### Chemin de persistance du schéma : `DATA_SCHEMA_CACHE` seul -L'utilisateur a demandé « écrire dans `DATA_SCHEMA_LOCAL` ». Mais en dev, -`DATA_SCHEMA_LOCAL = ../decp-processing/dist/schema.json` — un fichier d'un **autre -repo**. L'écraser au boot salirait ce repo. - -**Choix retenu (à confirmer en relecture) :** introduire un **chemin de cache -dédié que l'app possède**, distinct du fichier statique de secours. +On remplace `DATA_SCHEMA_LOCAL` (qui pointait, en dev, vers +`../decp-processing/dist/schema.json` — un fichier cross-repo qu'on ne veut pas +écraser) par un **cache unique possédé par l'app**. - `DATA_SCHEMA_PATH` (URL) — source primaire. - `DATA_SCHEMA_CACHE` (nouveau, ex. défaut `./schema.cache.json`) — écrit après - chaque fetch distant réussi ; lu en fallback n°1 (dernier distant fonctionnel). -- `DATA_SCHEMA_LOCAL` — graine statique de secours (fichier `decp-processing`), - **lue mais jamais écrite**. + chaque fetch distant réussi, lu en fallback. -Chaîne de résolution : `URL → cache → local statique → RuntimeError`. +Chaîne de résolution : `URL → cache → RuntimeError`. + +**Pourquoi c'est suffisant.** Le déploiement est en place sur un VM persistant +(`ssh → cd /var/www/APP_NAME → git pull → restart systemd`), donc le fichier de +cache survit aux déploiements — **même garantie de persistance que le DuckDB +réutilisé**. Tous les incidents constatés (env oubliée, parquet KO, URL schéma en +erreur) surviennent sur un **redéploiement** d'un hôte déjà chaud, où le cache a +déjà été écrit par un boot précédent réussi ⇒ couvert. + +**Seul cas non couvert (assumé) :** le _cold start absolu_ — un hôte qui n'a jamais +booté avec succès **et** URL distante down au même instant. Étroit, non-récurrent. +Fermable plus tard par une graine commitée in-repo si jamais il se matérialise +(YAGNI). + +**Contraintes :** + +- `DATA_SCHEMA_CACHE` (`./schema.cache.json`) doit être **`.gitignore`** — sinon le + `git pull` du déploiement entrerait en conflit. (Comme `decp.duckdb` aujourd'hui.) +- En dev, plus de fallback vers le schéma frais de `decp-processing` : on bascule + sur le cache (dernier schéma data.gouv). Acceptable, l'URL restant primaire. ## Design @@ -135,20 +150,19 @@ def _ensure_database() -> Path: ### Invariant 2 — Schéma (`src/utils/data.py`) -> Un schéma valide non-vide est toujours retourné si une source (distant, cache, -> ou local statique) en fournit un. Échec dur seulement si aucune. +> Un schéma valide non-vide est toujours retourné si une source (distant ou cache) +> en fournit un. Échec dur seulement si aucune. ```python def get_data_schema() -> dict: + cache_path = os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json") raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH")) # dict valide | None if raw is not None: - _persist_schema_cache(raw, os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")) + _persist_schema_cache(raw, cache_path) else: - raw = _load_schema_file(os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")) + raw = _load_schema_file(cache_path) if raw is None: - raw = _load_schema_file(os.getenv("DATA_SCHEMA_LOCAL", "")) - if raw is None: - raise RuntimeError("Aucun schéma disponible (distant, cache ni local).") + raise RuntimeError("Aucun schéma disponible (ni distant ni cache).") return OrderedDict((c["name"], c) for c in raw["fields"]) ``` @@ -173,11 +187,15 @@ Couvrir chaque branche de fallback. Sans dépendre du réseau réel. 2. URL renvoie une erreur HTTP (mock 500) ⇒ fallback cache. 3. URL renvoie un JSON malformé (sans `"fields"`) ⇒ fallback cache. 4. URL KO + cache présent ⇒ schéma du cache. -5. URL KO + cache absent + local statique présent ⇒ schéma local. -6. Toutes sources KO ⇒ `RuntimeError` claire. -7. Échec d'écriture du cache ⇒ schéma quand même retourné (non bloquant). +5. URL KO + cache absent ⇒ `RuntimeError` claire. +6. Échec d'écriture du cache ⇒ schéma quand même retourné (non bloquant). -**Bootstrap DuckDB (`_ensure_database`) :** 8. `should_rebuild` lève + DuckDB existant ⇒ réutilisé, pas d'exception. 9. `build_database` lève + DuckDB existant ⇒ réutilisé, pas d'exception. 10. Échec + **aucun** DuckDB (cold start) ⇒ ré-lève. 11. Cas nominal : rebuild nécessaire et possible ⇒ build effectué. +**Bootstrap DuckDB (`_ensure_database`) :** + +7. `should_rebuild` lève + DuckDB existant ⇒ réutilisé, pas d'exception. +8. `build_database` lève + DuckDB existant ⇒ réutilisé, pas d'exception. +9. Échec + **aucun** DuckDB (cold start) ⇒ ré-lève. +10. Cas nominal : rebuild nécessaire et possible ⇒ build effectué. Mocker `get_last_modified` / `build_database` / `httpx.get` ; utiliser des fichiers DuckDB et schéma temporaires (`tmp_path`). @@ -190,12 +208,16 @@ DuckDB et schéma temporaires (`tmp_path`). ## Variables d'environnement -| Variable | Rôle | Changement | -| ------------------------ | --------------------------------------- | -------------------- | -| `DATA_FILE_PARQUET_PATH` | Source parquet (URL ou chemin) | inchangé | -| `DATA_SCHEMA_PATH` | URL schéma (primaire) | inchangé | -| `DATA_SCHEMA_LOCAL` | Fichier schéma de secours statique | **lu, jamais écrit** | -| `DATA_SCHEMA_CACHE` | Cache last-known-good du schéma distant | **nouveau** | -| `DUCKDB_PATH` | Fichier DuckDB | inchangé | +| Variable | Rôle | Changement | +| ------------------------ | --------------------------------------- | ------------ | +| `DATA_FILE_PARQUET_PATH` | Source parquet (URL ou chemin) | inchangé | +| `DATA_SCHEMA_PATH` | URL schéma (primaire) | inchangé | +| `DATA_SCHEMA_LOCAL` | Ancien fichier de secours statique | **supprimé** | +| `DATA_SCHEMA_CACHE` | Cache last-known-good du schéma distant | **nouveau** | +| `DUCKDB_PATH` | Fichier DuckDB | inchangé | -Mettre à jour `.template.env` avec `DATA_SCHEMA_CACHE`. +À faire côté config : + +- Ajouter `DATA_SCHEMA_CACHE` à `.template.env`, retirer `DATA_SCHEMA_LOCAL` de + `.template.env` / `.env`. +- Ajouter `schema.cache.json` (ou la valeur de `DATA_SCHEMA_CACHE`) au `.gitignore`. From 20f81b573220d36479894ca8551c01d9e05a2539 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 11:23:35 +0200 Subject: [PATCH 085/151] docs: spec surfaces C/D (chargements pages au boot) (#78) Co-Authored-By: Claude Opus 4.8 --- ...otstrap-resilient-donnees-schema-design.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md b/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md index 6b0f74e..4472d09 100644 --- a/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md +++ b/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md @@ -177,6 +177,62 @@ Helpers : un échec d'écriture est loggé mais **non bloquant** (le schéma en mémoire reste valide). +### Invariant 3 — Chargements au niveau module des pages + +> L'import d'une page (exécuté au boot via `use_pages`) ne doit jamais tuer le +> démarrage à cause d'une ressource externe KO. Une ressource indisponible +> dégrade gracieusement l'affichage. + +Audit des chargements à l'import (tous les `layout` de pages sont au niveau +module ⇒ leur contenu s'exécute au boot). Deux points de rupture **externes** : + +**C — `src/pages/tableau.py:36-38`.** `get_last_modified(URL parquet)` fait un +HTTP HEAD **sans gestion d'erreur** (URL injoignable, en-tête `last-modified` +absent) ⇒ import KO ⇒ boot KO. C'est le même piège que `db.py`, mais dans une page. + +Correctif : un helper best-effort dans `src/utils/__init__.py` qui ne lève jamais +et retombe sur le mtime du DuckDB (garanti présent par l'Invariant 1) : + +```python +def get_data_update_timestamp(parquet_path: str, fallback_path: str | None = None) -> float | None: + """Date de MAJ des données, best-effort, sans jamais lever (usage au boot).""" + try: + return get_last_modified(parquet_path) + except Exception as e: + logger.warning(f"Date de mise à jour des données indisponible ({e})") + if fallback_path: + try: + return os.path.getmtime(fallback_path) + except OSError: + pass + return None +``` + +`tableau.py` l'utilise et gère le cas `None` (affiche « date inconnue », +`update_date_iso = ""`). + +**D — `src/pages/a-propos.py:103`.** `get_sources_tables(SOURCE_STATS_CSV_PATH)` +(`src/figures.py:121`) fait `pl.read_csv(source_path)` mais ne rattrape que +`URLError, HTTPError` — pas les erreurs Polars, ni `source_path` vide/`None`, ni +fichier absent ⇒ import KO ⇒ boot KO. + +Correctif : élargir le `except` et gérer le chemin vide : + +```python +def get_sources_tables(source_path) -> html.Div: + try: + if not source_path: + raise ValueError("SOURCE_STATS_CSV_PATH non défini") + dff = pl.read_csv(source_path) + except Exception as e: + logger.warning(f"Sources de données indisponibles ({e})") + return html.Div("Sources de données momentanément indisponibles.") + ... # suite inchangée +``` + +Hors périmètre des pages : `data/departements.json` + `.geojson` (fichiers +in-repo apportés par `git pull`, pas pilotés par env/URL — voir Hors périmètre). + ## Tests (TDD) Couvrir chaque branche de fallback. Sans dépendre du réseau réel. @@ -200,11 +256,24 @@ Couvrir chaque branche de fallback. Sans dépendre du réseau réel. Mocker `get_last_modified` / `build_database` / `httpx.get` ; utiliser des fichiers DuckDB et schéma temporaires (`tmp_path`). +**Chargements de pages (Invariant 3) :** + +11. `get_data_update_timestamp` : `get_last_modified` lève + `fallback_path` + existant ⇒ retourne le mtime du fallback (pas d'exception). +12. `get_data_update_timestamp` : tout KO (lève + pas de fallback) ⇒ `None`. +13. `get_data_update_timestamp` : cas nominal ⇒ retourne la valeur de + `get_last_modified` (mocké). +14. `get_sources_tables(None)` ⇒ `html.Div` de repli (pas d'exception). +15. `get_sources_tables("/inexistant.csv")` ⇒ `html.Div` de repli. +16. `get_sources_tables()` ⇒ `html.Div` contenant la `DataTable`. + ## Hors périmètre - Séparation des process API / Web (reportée — voir plus haut). - Surveillance / alerting externe (les logs `error`/`critical` suffisent pour ce lot). - Validation fine du contenu du parquet au-delà de « lisible par Polars/DuckDB ». +- Durcissement des `open()` in-repo (`data/departements.json` + `.geojson`) : + fichiers versionnés, apportés par `git pull`, jamais pilotés par env/URL (YAGNI). ## Variables d'environnement @@ -215,6 +284,7 @@ DuckDB et schéma temporaires (`tmp_path`). | `DATA_SCHEMA_LOCAL` | Ancien fichier de secours statique | **supprimé** | | `DATA_SCHEMA_CACHE` | Cache last-known-good du schéma distant | **nouveau** | | `DUCKDB_PATH` | Fichier DuckDB | inchangé | +| `SOURCE_STATS_CSV_PATH` | CSV stats sources (page À propos, D) | inchangé | À faire côté config : From afd6590c5448d77710481914251eac7227bb09e6 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 11:30:36 +0200 Subject: [PATCH 086/151] =?UTF-8?q?docs:=20plan=20d'impl=C3=A9mentation=20?= =?UTF-8?q?bootstrap=20r=C3=A9silient=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...6-12-bootstrap-resilient-donnees-schema.md | 665 ++++++++++++++++++ 1 file changed, 665 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-bootstrap-resilient-donnees-schema.md diff --git a/docs/superpowers/plans/2026-06-12-bootstrap-resilient-donnees-schema.md b/docs/superpowers/plans/2026-06-12-bootstrap-resilient-donnees-schema.md new file mode 100644 index 0000000..c674eca --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-bootstrap-resilient-donnees-schema.md @@ -0,0 +1,665 @@ +# Bootstrap résilient des données et du schéma — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rendre le démarrage de decp.info résilient aux ressources externes KO (parquet, schéma, stats), pour que l'API partagée ne tombe plus à cause d'un déploiement défaillant. + +**Architecture:** Trois invariants. (1) Le DuckDB est réutilisé si la reconstruction échoue. (2) Le schéma suit la chaîne `URL → cache → RuntimeError`, le dernier schéma distant fonctionnel étant persisté localement. (3) Les chargements de pages au boot ne lèvent jamais sur une ressource externe KO. Une tâche préalable répare le baseline de tests laissé rouge par le merge de `main`. + +**Tech Stack:** Python, Polars, DuckDB, httpx, flask-caching, pytest (+ monkeypatch). + +**Spec de référence:** `docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md` + +--- + +## Structure des fichiers + +| Fichier | Responsabilité | Action | +| ------------------------------------- | ---------------------------------------- | ----------------------------- | +| `tests/test_db.py` | Tests bootstrap DuckDB | Modifier (réparer + ajouter) | +| `tests/conftest.py` | Setup déterministe des tests | Modifier (seed schéma) | +| `tests/schema.fixture.json` | Schéma complet figé pour tests (offline) | Créer (commité) | +| `tests/test_schema.py` | Tests résolution schéma | Créer | +| `tests/test_page_loads.py` | Tests chargements best-effort (C/D) | Créer | +| `src/utils/data.py` | Résolution schéma | Modifier | +| `src/db.py` | Bootstrap DuckDB | Modifier (`_ensure_database`) | +| `src/utils/__init__.py` | Helper date MAJ best-effort | Modifier (ajout fonction) | +| `src/pages/tableau.py` | Date MAJ au niveau module | Modifier | +| `src/figures.py` | `get_sources_tables` | Modifier | +| `.template.env`, `.env`, `.gitignore` | Config | Modifier | + +--- + +## Task 1 : Réparer le baseline de tests `test_db.py` + +Le merge de `main` a changé `build_database(db_path)` (1 arg, parquet lu via env) et memoïsé `get_last_modified` (besoin du contexte d'app). Trois corrections pour repartir au vert. **Aucune logique applicative ne change ici.** + +**Files:** + +- Modify: `tests/test_db.py` + +- [ ] **Step 1 : Corriger la fixture `built_db` (signature `build_database`)** + +Dans `tests/test_db.py`, remplacer : + +```python + from src.db import build_database + + build_database(db_path, parquet_path) + return db_path +``` + +par : + +```python + from src.db import build_database + + build_database(db_path) + return db_path +``` + +(L'env `DATA_FILE_PARQUET_PATH` est déjà posé juste au-dessus dans la fixture.) + +- [ ] **Step 2 : Corriger `test_concurrent_build_serialized`** + +Ajouter `monkeypatch` à la signature et poser l'env du parquet ; corriger l'appel `build_database`. + +Remplacer la ligne de signature : + +```python +def test_concurrent_build_serialized(tmp_path): +``` + +par : + +```python +def test_concurrent_build_serialized(tmp_path, monkeypatch): +``` + +Juste après `df.write_parquet(parquet_path)`, ajouter : + +```python + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path)) +``` + +Et dans `worker()`, remplacer : + +```python + if db.should_rebuild(db_path, parquet_path): + db.build_database(db_path, parquet_path) +``` + +par : + +```python + if db.should_rebuild(db_path, parquet_path): + db.build_database(db_path) +``` + +- [ ] **Step 3 : Corriger les 2 tests prod de `should_rebuild` (memoïsation)** + +`should_rebuild` non-dev appelle `get_last_modified`, memoïsé et inutilisable hors contexte d'app en test. On le monkeypatche pour tester la logique de comparaison de dates. + +Dans `test_should_rebuild_prod_when_parquet_newer`, juste avant l'`assert`, ajouter : + +```python + monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime) +``` + +Dans `test_should_not_rebuild_prod_when_parquet_older`, juste avant l'`assert`, ajouter la même ligne : + +```python + monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime) +``` + +- [ ] **Step 4 : Lancer les tests, vérifier le vert** + +Run: `rtk proxy python -m pytest tests/test_db.py -q` +Expected: tous PASS (plus aucun `TypeError`/`AttributeError`). + +- [ ] **Step 5 : Commit** + +```bash +git add tests/test_db.py +git commit -m "test: réparer le baseline test_db cassé par le merge (#78)" +``` + +--- + +## Task 2 : Schéma résilient — chaîne `URL → cache → RuntimeError` + +Réécrire `get_data_schema` avec helpers robustes et persistance du dernier schéma distant fonctionnel. Supprimer `DATA_SCHEMA_LOCAL` au profit de `DATA_SCHEMA_CACHE`. + +**Files:** + +- Create: `tests/schema.fixture.json`, `tests/test_schema.py` +- Modify: `tests/conftest.py`, `src/utils/data.py`, `.template.env`, `.env`, `.gitignore` + +- [ ] **Step 1 : Créer le fixture schéma complet (offline, déterministe)** + +Run: + +```bash +cp ../decp-processing/dist/schema.json tests/schema.fixture.json +test -s tests/schema.fixture.json && python -c "import json;assert 'fields' in json.load(open('tests/schema.fixture.json'))" && echo OK +``` + +Expected: `OK` (le fixture contient bien une clé `fields`). + +- [ ] **Step 2 : Rendre la résolution du schéma déterministe en test (conftest)** + +Dans `tests/conftest.py`, ajouter `import json` en tête (avec les autres imports) puis, au niveau module **avant** toute logique existante (juste après la ligne `_DB_PATH = Path(...)`), ajouter : + +```python +# Schéma déterministe et hors-ligne pour les tests : on pointe le cache sur un +# fixture commité et on désactive la récupération distante. +_SCHEMA_FIXTURE = Path(os.path.abspath("tests/schema.fixture.json")) +os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_FIXTURE) +os.environ.pop("DATA_SCHEMA_PATH", None) +``` + +- [ ] **Step 3 : Écrire les tests schéma (échouent d'abord)** + +Créer `tests/test_schema.py` : + +```python +import json + +import httpx +import pytest + +from src.utils import data as data_mod + +VALID = {"fields": [{"name": "uid", "title": "UID"}, {"name": "objet"}]} + + +class FakeResp: + def __init__(self, payload, ok=True, bad_json=False): + self._payload = payload + self._ok = ok + self._bad_json = bad_json + + def raise_for_status(self): + if not self._ok: + raise httpx.HTTPError("boom") + return self + + def json(self): + if self._bad_json: + raise json.JSONDecodeError("bad", "", 0) + return self._payload + + +def test_remote_ok_returns_schema_and_writes_cache(tmp_path, monkeypatch): + cache = tmp_path / "schema.cache.json" + monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x") + monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache)) + monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID)) + result = data_mod.get_data_schema() + assert "uid" in result + assert json.loads(cache.read_text())["fields"][0]["name"] == "uid" + + +def test_remote_http_error_falls_back_to_cache(tmp_path, monkeypatch): + cache = tmp_path / "schema.cache.json" + cache.write_text(json.dumps(VALID)) + monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x") + monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache)) + monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(None, ok=False)) + assert "uid" in data_mod.get_data_schema() + + +def test_remote_malformed_falls_back_to_cache(tmp_path, monkeypatch): + cache = tmp_path / "schema.cache.json" + cache.write_text(json.dumps(VALID)) + monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x") + monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache)) + monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp({"nope": 1})) + assert "uid" in data_mod.get_data_schema() + + +def test_no_url_uses_cache(tmp_path, monkeypatch): + cache = tmp_path / "schema.cache.json" + cache.write_text(json.dumps(VALID)) + monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False) + monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache)) + assert "uid" in data_mod.get_data_schema() + + +def test_no_source_raises(tmp_path, monkeypatch): + monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False) + monkeypatch.setenv("DATA_SCHEMA_CACHE", str(tmp_path / "missing.json")) + with pytest.raises(RuntimeError): + data_mod.get_data_schema() + + +def test_cache_write_failure_is_non_blocking(tmp_path, monkeypatch): + # parent inexistant => l'écriture du cache échoue, mais le schéma est renvoyé + cache = tmp_path / "nodir" / "schema.cache.json" + monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x") + monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache)) + monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID)) + assert "uid" in data_mod.get_data_schema() +``` + +- [ ] **Step 4 : Lancer les tests, vérifier l'échec** + +Run: `rtk proxy python -m pytest tests/test_schema.py -q` +Expected: FAIL (les helpers/comportements n'existent pas encore ; `get_data_schema` actuel plante différemment). + +- [ ] **Step 5 : Réécrire `get_data_schema` + helpers** + +Dans `src/utils/data.py`, remplacer entièrement la fonction `get_data_schema` (actuellement lignes ~68-95) par : + +```python +def _validate_schema(raw) -> dict | None: + if isinstance(raw, dict) and isinstance(raw.get("fields"), list) and raw["fields"]: + return raw + return None + + +def _fetch_remote_schema(url: str | None) -> dict | None: + if not url: + return None + try: + raw = get(url, follow_redirects=True).raise_for_status().json() + except (httpx.HTTPError, json.JSONDecodeError) as e: + logger.error(f"Schéma distant indisponible ({url}) : {e}") + return None + return _validate_schema(raw) + + +def _load_schema_file(path: str) -> dict | None: + if not path or not os.path.exists(path): + return None + try: + with open(path) as f: + raw = json.load(f) + except (OSError, json.JSONDecodeError) as e: + logger.error(f"Schéma local illisible ({path}) : {e}") + return None + return _validate_schema(raw) + + +def _persist_schema_cache(raw: dict, path: str) -> None: + if not path: + return + try: + tmp = f"{path}.tmp" + with open(tmp, "w") as f: + json.dump(raw, f) + os.replace(tmp, path) + except OSError as e: + logger.warning(f"Écriture du cache schéma échouée ({path}) : {e}") + + +def get_data_schema() -> dict: + cache_path = os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json") + raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH")) + if raw is not None: + _persist_schema_cache(raw, cache_path) + else: + raw = _load_schema_file(cache_path) + if raw is None: + raise RuntimeError("Aucun schéma disponible (ni distant ni cache).") + return OrderedDict((c["name"], c) for c in raw["fields"]) +``` + +Vérifier que les imports en tête de `src/utils/data.py` couvrent : `json`, `os`, `OrderedDict`, `httpx`, `get` (déjà présents : `from httpx import HTTPError, get`). `HTTPError` peut devenir inutilisé — voir Step 7. + +- [ ] **Step 6 : Lancer les tests, vérifier le vert** + +Run: `rtk proxy python -m pytest tests/test_schema.py -q` +Expected: 6 PASS. + +- [ ] **Step 7 : Nettoyer imports + config + gitignore** + +Dans `src/utils/data.py`, si `HTTPError` n'est plus utilisé, remplacer `from httpx import HTTPError, get` par `from httpx import get` (garder `import httpx`). Vérifier avec : + +Run: `rtk proxy python -m ruff check src/utils/data.py` +Expected: pas d'erreur F401. + +Dans `.gitignore`, ajouter sous la ligne `**/decp.duckdb` : + +``` +**/schema.cache.json +``` + +Dans `.template.env`, supprimer la ligne `DATA_SCHEMA_PATH_LOCAL=...` et ajouter : + +``` +DATA_SCHEMA_CACHE=./schema.cache.json +``` + +Dans `.env` (local, non versionné), supprimer la ligne `DATA_SCHEMA_LOCAL=...` et ajouter `DATA_SCHEMA_CACHE=./schema.cache.json`. + +- [ ] **Step 8 : Commit** + +```bash +git add tests/schema.fixture.json tests/test_schema.py tests/conftest.py src/utils/data.py .template.env .gitignore +git commit -m "feat: schéma résilient URL→cache + suppression DATA_SCHEMA_LOCAL (#78)" +``` + +--- + +## Task 3 : Bootstrap DuckDB résilient + +Ajouter le garde-fou `try/except` dans `_ensure_database` : réutiliser le DuckDB existant si la reconstruction échoue ; ne lever qu'en cold start. + +**Files:** + +- Modify: `src/db.py:117-128` (`_ensure_database`) +- Test: `tests/test_db.py` + +- [ ] **Step 1 : Écrire les tests (échouent d'abord)** + +Ajouter à la fin de `tests/test_db.py` : + +```python +def _raise(*args, **kwargs): + raise RuntimeError("boom") + + +def test_ensure_database_reuses_db_when_should_rebuild_raises(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" + dbf.write_bytes(b"existing") + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") + monkeypatch.setattr(db, "should_rebuild", _raise) + result = db._ensure_database() # ne doit pas lever + assert result == dbf + assert dbf.read_bytes() == b"existing" + + +def test_ensure_database_reuses_db_when_build_raises(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" + dbf.write_bytes(b"existing") + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") + monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) + monkeypatch.setattr(db, "build_database", _raise) + db._ensure_database() # ne doit pas lever + assert dbf.read_bytes() == b"existing" + + +def test_ensure_database_raises_on_cold_start(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" # n'existe pas + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") + monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) + monkeypatch.setattr(db, "build_database", _raise) + with pytest.raises(RuntimeError): + db._ensure_database() + + +def test_ensure_database_builds_when_needed(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" + dbf.write_bytes(b"old") + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://x") + called = {} + monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) + monkeypatch.setattr(db, "build_database", lambda p: called.setdefault("built", p)) + db._ensure_database() + assert called.get("built") == dbf +``` + +Ajouter `import pytest` en tête de `tests/test_db.py` s'il n'y est pas déjà (il y est). + +- [ ] **Step 2 : Lancer, vérifier l'échec** + +Run: `rtk proxy python -m pytest tests/test_db.py -q -k ensure_database` +Expected: FAIL (le `try/except` n'existe pas ; les exceptions remontent). + +- [ ] **Step 3 : Implémenter le garde-fou** + +Dans `src/db.py`, remplacer `_ensure_database` (lignes ~117-128) par : + +```python +def _ensure_database() -> Path: + db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb")) + parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "") + lock_path = db_path.with_suffix(".duckdb.lock") + db_exists = db_path.exists() + + with open(lock_path, "w") as lock_fd: + fcntl.flock(lock_fd, fcntl.LOCK_EX) + try: + if should_rebuild(db_path, parquet_path): + build_database(db_path) + else: + logger.debug("Base de données déjà disponible et à jour.") + except Exception as e: + if db_exists: + logger.error( + f"Bootstrap données KO ({e}). " + f"Réutilisation du DuckDB existant : {db_path}" + ) + else: + logger.critical("Aucune base DuckDB et reconstruction impossible.") + raise + return db_path +``` + +- [ ] **Step 4 : Lancer, vérifier le vert** + +Run: `rtk proxy python -m pytest tests/test_db.py -q` +Expected: tous PASS (anciens + 4 nouveaux). + +- [ ] **Step 5 : Commit** + +```bash +git add src/db.py tests/test_db.py +git commit -m "feat: réutiliser le DuckDB existant si le bootstrap échoue (#78)" +``` + +--- + +## Task 4 : Chargements de pages best-effort (C + D) + +`tableau.py` (date MAJ via `get_last_modified`) et `a-propos.py` (stats via `get_sources_tables`) chargent au boot et peuvent tuer le démarrage. On les rend best-effort. + +**Files:** + +- Create: `tests/test_page_loads.py` +- Modify: `src/utils/__init__.py`, `src/pages/tableau.py`, `src/figures.py` + +- [ ] **Step 1 : Écrire les tests (échouent d'abord)** + +Créer `tests/test_page_loads.py` : + +```python +import os + + +def test_update_timestamp_falls_back_to_db_mtime(tmp_path, monkeypatch): + import src.utils as u + from src.utils import get_data_update_timestamp + + def boom(*a, **k): + raise RuntimeError("net down") + + monkeypatch.setattr(u, "get_last_modified", boom) + fb = tmp_path / "decp.duckdb" + fb.write_bytes(b"x") + assert get_data_update_timestamp("http://x", str(fb)) == os.path.getmtime(str(fb)) + + +def test_update_timestamp_none_when_all_fail(monkeypatch): + import src.utils as u + from src.utils import get_data_update_timestamp + + def boom(*a, **k): + raise RuntimeError("net down") + + monkeypatch.setattr(u, "get_last_modified", boom) + assert get_data_update_timestamp("http://x", None) is None + + +def test_update_timestamp_nominal(monkeypatch): + import src.utils as u + from src.utils import get_data_update_timestamp + + monkeypatch.setattr(u, "get_last_modified", lambda p: 123.0) + assert get_data_update_timestamp("http://x", None) == 123.0 + + +def test_sources_tables_none_path(): + from src.figures import get_sources_tables + + div = get_sources_tables(None) + assert "indisponible" in str(div.children).lower() + + +def test_sources_tables_missing_file(): + from src.figures import get_sources_tables + + div = get_sources_tables("/does/not/exist.csv") + assert "indisponible" in str(div.children).lower() + + +def test_sources_tables_valid_csv(tmp_path): + from dash import dash_table + + from src.figures import get_sources_tables + + csv = tmp_path / "s.csv" + csv.write_text( + "nom,organisation,nb_marchés,nb_acheteurs,code,url,unique\n" + "Source A,Org A,5,2,XA,http://a,1\n" + ) + div = get_sources_tables(str(csv)) + assert isinstance(div.children, dash_table.DataTable) +``` + +- [ ] **Step 2 : Lancer, vérifier l'échec** + +Run: `rtk proxy python -m pytest tests/test_page_loads.py -q` +Expected: FAIL (`get_data_update_timestamp` n'existe pas ; `get_sources_tables(None)` plante). + +- [ ] **Step 3 : Ajouter `get_data_update_timestamp` dans `src/utils/__init__.py`** + +À la fin de `src/utils/__init__.py`, ajouter : + +```python +def get_data_update_timestamp( + parquet_path: str, fallback_path: str | None = None +) -> float | None: + """Date de MAJ des données, best-effort, sans jamais lever (usage au boot).""" + try: + return get_last_modified(parquet_path) + except Exception as e: + logger.warning(f"Date de mise à jour des données indisponible ({e})") + if fallback_path: + try: + return os.path.getmtime(fallback_path) + except OSError: + pass + return None +``` + +(`os` et `logger` sont déjà disponibles dans ce module.) + +- [ ] **Step 4 : Utiliser le helper dans `tableau.py`** + +Dans `src/pages/tableau.py`, remplacer l'import ligne 24 : + +```python +from src.utils import get_last_modified, logger +``` + +par : + +```python +from src.utils import get_data_update_timestamp, logger +``` + +Et remplacer les lignes 36-38 : + +```python +update_date_timestamp = get_last_modified(os.getenv("DATA_FILE_PARQUET_PATH", "")) +update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y") +update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat() +``` + +par : + +```python +update_date_timestamp = get_data_update_timestamp( + os.getenv("DATA_FILE_PARQUET_PATH", ""), + os.getenv("DUCKDB_PATH", "./decp.duckdb"), +) +if update_date_timestamp is not None: + update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y") + update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat() +else: + update_date = "date inconnue" + update_date_iso = "" +``` + +- [ ] **Step 5 : Élargir `get_sources_tables` dans `src/figures.py`** + +Dans `src/figures.py` (`get_sources_tables`, ~lignes 122-125), remplacer : + +```python + try: + dff = pl.read_csv(source_path) + except (URLError, HTTPError): + return html.Div("Erreur de connexion") +``` + +par : + +```python + try: + if not source_path: + raise ValueError("SOURCE_STATS_CSV_PATH non défini") + dff = pl.read_csv(source_path) + except Exception as e: + logger.warning(f"Sources de données indisponibles ({e})") + return html.Div("Sources de données momentanément indisponibles.") +``` + +Si `URLError`/`HTTPError` (import ligne 3 `from urllib.error import HTTPError, URLError`) ne sont plus utilisés ailleurs dans le fichier, supprimer cet import. + +Run: `rtk proxy python -m ruff check src/figures.py` +Expected: pas d'erreur F401. + +- [ ] **Step 6 : Lancer, vérifier le vert** + +Run: `rtk proxy python -m pytest tests/test_page_loads.py -q` +Expected: 6 PASS. + +- [ ] **Step 7 : Smoke test — l'import des modules modifiés ne casse pas** + +Run: `rtk proxy python -c "import src.figures, src.pages.tableau; print('import OK')"` +Expected: `import OK`. +(NB : `a-propos.py` a un tiret, non importable par nom — son correctif `get_sources_tables` est couvert par les tests unitaires du Step 6 et la page sera validée par la suite Selenium au Step 8.) + +- [ ] **Step 8 : Suite complète** + +Run: `rtk proxy python -m pytest -q` +Expected: vert (hors tests Selenium nécessitant Chrome, à lancer si l'environnement le permet). + +- [ ] **Step 9 : Commit** + +```bash +git add tests/test_page_loads.py src/utils/__init__.py src/pages/tableau.py src/figures.py +git commit -m "feat: chargements de pages best-effort au boot (tableau, sources) (#78)" +``` + +--- + +## Notes d'exécution + +- **Dev hors-ligne 1er run :** « cache seul » supprime le fallback in-repo. Au tout premier démarrage sur une machine sans `schema.cache.json` ni réseau, le boot lèvera `RuntimeError`. En conditions normales (URL OK une fois, ou cache déjà présent) c'est transparent. Les tests sont rendus déterministes via `tests/schema.fixture.json` (Task 2). +- **CHANGELOG :** penser à ajouter une entrée (résilience bootstrap données/schéma) avant de finaliser la PR #78, si le projet le tient à jour. +- **`get_last_modified` reste memoïsé** : non modifié ici ; le cache FileSystem est vidé à chaque boot (`rmtree` dans `app.py`), donc pas de last-modified périmé entre déploiements. From 21c65c34fb7571acfdb9261ea88c4a92c9c08c33 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 11:37:54 +0200 Subject: [PATCH 087/151] =?UTF-8?q?test:=20r=C3=A9parer=20le=20baseline=20?= =?UTF-8?q?test=5Fdb=20cass=C3=A9=20par=20le=20merge=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- tests/test_db.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/test_db.py b/tests/test_db.py index bba7de5..10e9725 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -31,6 +31,7 @@ def test_should_rebuild_prod_when_parquet_newer(parquet_and_db, monkeypatch): os.utime(db, (now, now)) os.utime(parquet, (now + 10, now + 10)) monkeypatch.setenv("DEVELOPMENT", "false") + monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime) assert should_rebuild(db, parquet) is True @@ -42,6 +43,7 @@ def test_should_not_rebuild_prod_when_parquet_older(parquet_and_db, monkeypatch) os.utime(parquet, (now, now)) os.utime(db, (now + 10, now + 10)) monkeypatch.setenv("DEVELOPMENT", "false") + monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime) assert should_rebuild(db, parquet) is False @@ -66,6 +68,7 @@ def test_should_rebuild_dev_when_rebuild_forced(parquet_and_db, monkeypatch): os.utime(parquet, (now + 10, now + 10)) monkeypatch.setenv("DEVELOPMENT", "true") monkeypatch.setenv("REBUILD_DUCKDB", "true") + monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime) assert should_rebuild(db, parquet) is True @@ -145,7 +148,7 @@ def built_db(tmp_path, monkeypatch): from src.db import build_database - build_database(db_path, parquet_path) + build_database(db_path) return db_path @@ -190,8 +193,15 @@ def test_build_creates_derived_tables(built_db): def test_query_marches_returns_polars_frame(built_db, monkeypatch): - monkeypatch.setenv( - "DATA_FILE_PARQUET_PATH", str(built_db.parent / "source.parquet") + parquet_path = built_db.parent / "source.parquet" + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path)) + # Patch on both the source module and dst namespace: the reload re-imports + # get_last_modified from src.utils, so src.utils must be patched to survive. + monkeypatch.setattr( + "src.utils.get_last_modified", lambda p: parquet_path.stat().st_mtime + ) + monkeypatch.setattr( + "src.db.get_last_modified", lambda p: parquet_path.stat().st_mtime ) # Force src.db to load pointing at this test DB. import importlib @@ -239,7 +249,7 @@ def test_query_marches_with_offset(): assert set(page_0["uid"].to_list()).isdisjoint(set(page_1["uid"].to_list())) -def test_concurrent_build_serialized(tmp_path): +def test_concurrent_build_serialized(tmp_path, monkeypatch): """Multiple threads calling _ensure_database must serialize via flock. Only one should actually build; others wait, see the fresh DB, and skip. @@ -269,6 +279,10 @@ def test_concurrent_build_serialized(tmp_path): } ) df.write_parquet(parquet_path) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path)) + monkeypatch.setattr( + "src.db.get_last_modified", lambda p: parquet_path.stat().st_mtime + ) db_path = tmp_path / "decp.duckdb" lock_path = db_path.with_suffix(".duckdb.lock") @@ -283,7 +297,7 @@ def test_concurrent_build_serialized(tmp_path): fcntl.flock(lf.fileno(), fcntl.LOCK_EX) try: if db.should_rebuild(db_path, parquet_path): - db.build_database(db_path, parquet_path) + db.build_database(db_path) finally: fcntl.flock(lf.fileno(), fcntl.LOCK_UN) except BaseException as exc: From 42ba73cd366e4826bd6792a8c38144021f5bad95 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 13:26:03 +0200 Subject: [PATCH 088/151] =?UTF-8?q?feat:=20sch=C3=A9ma=20r=C3=A9silient=20?= =?UTF-8?q?URL=E2=86=92cache=20+=20suppression=20DATA=5FSCHEMA=5FLOCAL=20(?= =?UTF-8?q?#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + .template.env | 2 +- src/utils/data.py | 78 ++++--- tests/conftest.py | 6 + tests/schema.fixture.json | 468 ++++++++++++++++++++++++++++++++++++++ tests/test_schema.py | 77 +++++++ 6 files changed, 603 insertions(+), 29 deletions(-) create mode 100644 tests/schema.fixture.json create mode 100644 tests/test_schema.py diff --git a/.gitignore b/.gitignore index 6c17783..2bff50a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ build **/decp.duckdb **/decp.duckdb.tmp **/decp.duckdb.lock +**/schema.cache.json diff --git a/.template.env b/.template.env index cb227e4..6b9744a 100644 --- a/.template.env +++ b/.template.env @@ -9,7 +9,7 @@ ANNOUNCEMENTS= # Chemin vers le schéma de données DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a -DATA_SCHEMA_PATH_LOCAL=../schema.json +DATA_SCHEMA_CACHE=./schema.cache.json # Colonnes masquées par défaut DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom, titulaire_id, dateNotification, dureeMois, acheteur_departement_code, sourceDataset" diff --git a/src/utils/data.py b/src/utils/data.py index c5304d5..339f826 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -2,7 +2,6 @@ import json import logging import os from collections import OrderedDict -from pathlib import Path import httpx import polars as pl @@ -65,34 +64,57 @@ def get_departement_region(code_postal: str | None): return "", "", "" +def _validate_schema(raw) -> dict | None: + if isinstance(raw, dict) and isinstance(raw.get("fields"), list) and raw["fields"]: + return raw + return None + + +def _fetch_remote_schema(url: str | None) -> dict | None: + if not url: + return None + try: + raw = get(url, follow_redirects=True).raise_for_status().json() + except (httpx.HTTPError, json.JSONDecodeError) as e: + logger.error(f"Schéma distant indisponible ({url}) : {e}") + return None + return _validate_schema(raw) + + +def _load_schema_file(path: str) -> dict | None: + if not path or not os.path.exists(path): + return None + try: + with open(path) as f: + raw = json.load(f) + except (OSError, json.JSONDecodeError) as e: + logger.error(f"Schéma local illisible ({path}) : {e}") + return None + return _validate_schema(raw) + + +def _persist_schema_cache(raw: dict, path: str) -> None: + if not path: + return + try: + tmp = f"{path}.tmp" + with open(tmp, "w") as f: + json.dump(raw, f) + os.replace(tmp, path) + except OSError as e: + logger.warning(f"Écriture du cache schéma échouée ({path}) : {e}") + + def get_data_schema() -> dict: - # Récupération du schéma des données tabulaires - url = os.getenv("DATA_SCHEMA_PATH") - local_path = Path(os.getenv("DATA_SCHEMA_LOCAL", "")) - - original_schema = {} - if url: - try: - original_schema: dict = get(url, follow_redirects=True).json() - except ( - httpx.ReadTimeout, - httpx.ReadError, - httpx.ConnectError, - httpx.ConnectTimeout, - ): - logger.error(f"Erreur HTTP lors de la récupération du schéma ({url})") - - if os.path.exists(local_path) and original_schema == {}: - with open(local_path) as f: - original_schema: dict = json.load(f) - logger.info(f"Utilisation du schéma local ({local_path})") - - new_schema = OrderedDict() - - for col in original_schema["fields"]: - new_schema[col["name"]] = col - - return new_schema + cache_path = os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json") + raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH")) + if raw is not None: + _persist_schema_cache(raw, cache_path) + else: + raw = _load_schema_file(cache_path) + if raw is None: + raise RuntimeError("Aucun schéma disponible (ni distant ni cache).") + return OrderedDict((c["name"], c) for c in raw["fields"]) def prepare_dashboard_data(**filter_params) -> pl.DataFrame: diff --git a/tests/conftest.py b/tests/conftest.py index f8d702f..9ef9b6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,6 +42,12 @@ _TEST_DATA = [ _PARQUET_PATH = Path(os.path.abspath("tests/test.parquet")) _DB_PATH = Path(os.path.abspath("decp.duckdb")) +# Schéma déterministe et hors-ligne pour les tests : on pointe le cache sur un +# fixture commité et on désactive la récupération distante. +_SCHEMA_FIXTURE = Path(os.path.abspath("tests/schema.fixture.json")) +os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_FIXTURE) +os.environ.pop("DATA_SCHEMA_PATH", None) + def _cleanup_db_artifacts() -> None: for artifact in ( diff --git a/tests/schema.fixture.json b/tests/schema.fixture.json new file mode 100644 index 0000000..fc10e53 --- /dev/null +++ b/tests/schema.fixture.json @@ -0,0 +1,468 @@ +{ + "fields": [ + { + "name": "acheteur_categorie", + "type": "string", + "title": "Catégorie de l'acheteur", + "description": "Catégorie de l'acheteur selon son code juridique INSEE.", + "short_title": "Catégorie acheteur", + "enum": [ + "Commune", + "Groupement de communes", + "Département", + "Département outre-mer", + "Région", + "État", + "Établissement hospitalier", + "EPIC", + "Syndicat mixte" + ] + }, + { + "name": "acheteur_commune_code", + "type": "string", + "title": "Commune de l'acheteur (code)", + "description": "Code de la commune où se trouve l'acheteur.", + "short_title": "Commune ach. (code)" + }, + { + "name": "acheteur_commune_nom", + "type": "string", + "title": "Commune de l'acheteur", + "description": "Nom de la commune où se trouve l'acheteur.", + "short_title": "Commune acheteur" + }, + { + "name": "acheteur_departement_code", + "type": "string", + "title": "Département de l'acheteur (code)", + "description": "Code du département où se trouve l'acheteur.", + "short_title": "Département ach. (code)" + }, + { + "name": "acheteur_departement_nom", + "type": "string", + "title": "Département de l'acheteur", + "description": "Nom du département où se trouve l'acheteur.", + "short_title": "Département acheteur" + }, + { + "name": "acheteur_id", + "type": "integer", + "title": "SIRET acheteur", + "description": "Identifiant de l'établissement de l'acheteur (SIRET), référencé dans la base SIRENE de l'INSEE.", + "short_title": null + }, + { + "name": "acheteur_latitude", + "type": "number", + "title": "Latitude de l'acheteur", + "description": "Latitude des coordonnées géographiques de l'acheteur.", + "short_title": "Latitude acheteur" + }, + { + "name": "acheteur_longitude", + "type": "number", + "title": "Longitude de l'acheteur", + "description": "Longitude des coordonnées géographiques de l'acheteur.", + "short_title": "Longitude acheteur" + }, + { + "name": "acheteur_nom", + "type": "string", + "title": "Nom acheteur", + "description": "Nom de l'acheteur tel que renseigné dans la base SIRENE de l'INSEE.", + "short_title": "Acheteur" + }, + { + "name": "acheteur_region_code", + "type": "string", + "title": "Région de l'acheteur (code)", + "description": "Code de la région où se trouve l'acheteur.", + "short_title": "Région ach. (code)" + }, + { + "name": "acheteur_region_nom", + "type": "string", + "title": "Région de l'acheteur", + "description": "Nom de la région où se trouve l'acheteur.", + "short_title": "Région acheteur" + }, + { + "name": "attributionAvance", + "type": "boolean", + "title": "Attribution avance", + "description": "Si une avance sur le montant du marché public a été attribuée aux titulaires.", + "short_title": null + }, + { + "name": "ccag", + "type": "string", + "title": "CCAG", + "description": "Cahier des clauses administratives générales et techniques (CCAG) utilisé pour le marché public.", + "short_title": null, + "enum": [ + "Travaux", + "Maitrise d'œuvre", + "Fournitures courantes et services", + "Marchés industriels", + "Prestations intellectuelles", + "Techniques de l'information et de la communication" + ] + }, + { + "name": "codeCPV", + "type": "string", + "title": "Code CPV", + "description": "Catégorie de bien, service ou travaux achetés, selon le Vocabulaire commun pour les marchés publics (CPV).", + "short_title": "CPV" + }, + { + "name": "considerationsEnvironnementales", + "type": "string", + "title": "Considérations environnementales", + "description": "Les considérations environnementales prévues dans le marché public.", + "short_title": "Cons. environnementales", + "enum": ["Clause environnementale", "Critère environnemental"] + }, + { + "name": "considerationsSociales", + "type": "string", + "title": "Considérations sociales", + "description": "Les considérations sociales prévues dans le marché public.", + "short_title": "Cons. sociales", + "enum": ["Clause sociale", "Critère social", "Marché réservé"] + }, + { + "name": "dateNotification", + "type": "date", + "title": "Date notification", + "description": "Date à laquelle le marché public ou de la modification a été notifiée aux titulaires du marché public.", + "short_title": null, + "format": "default" + }, + { + "name": "datePublicationDonnees", + "type": "date", + "title": "Date publication données", + "description": "Date à laquelle les données du marché public ou de la modification ont été publiées sur data.gouv.fr.", + "short_title": "Date pub. données", + "format": "default" + }, + { + "name": "donneesActuelles", + "type": "boolean", + "title": "Données actuelles", + "description": "Si les données de cette ligne sont les données actuelles du marché public, une fois les éventuelles modifications prises en compte.", + "short_title": null + }, + { + "name": "dureeMois", + "type": "integer", + "title": "Durée (mois)", + "description": "Durée en mois du marché attribué.", + "short_title": null + }, + { + "name": "dureeRestanteMois", + "type": "number", + "title": "Durée restante (mois)", + "description": "Durée approximative en mois restante dans le marché, en tenant compte de la date de notification et de la durée du marché. Ce nombre ne peut être inférieur à 0.", + "short_title": null + }, + { + "name": "formePrix", + "type": "string", + "title": "Forme prix", + "description": "La forme du prix du marché public. Unitaire, Forfaitaire ou Mixte.", + "short_title": null + }, + { + "name": "id", + "type": "string", + "title": "Identifiant interne", + "description": "Identifiant attribué par l'acheteur, censé être unique au sein de ses marchés.", + "short_title": "Id. interne" + }, + { + "name": "idAccordCadre", + "type": "string", + "title": "Identifiant accord-cadre", + "description": "Pour un marché subséquent, l'identifiant interne du marché public relevant de la technique d'achat accord-cadre auquel il est lié.", + "short_title": "Id. accord-cadre" + }, + { + "name": "lieuExecution_code", + "type": "integer", + "title": "Code lieu exécution", + "description": "Code du lieu d'exécution du marché public. Le type de code est renseigné par 'Type code lieu exécution'.", + "short_title": "Lieu exécution" + }, + { + "name": "lieuExecution_typeCode", + "type": "string", + "title": "Type code lieu exécution", + "description": "Type du code du lieu d'exécution.", + "short_title": "Type lieu exécution", + "enum": [ + "Code postal", + "Code commune", + "Code arrondissement", + " Code canton", + "Code département", + "Code région", + "Code pays" + ] + }, + { + "name": "marcheInnovant", + "type": "boolean", + "title": "Marché innovant", + "description": "Si le marché comporte des travaux, services ou fournitures innovantes.", + "short_title": null + }, + { + "name": "modalitesExecution", + "type": "string", + "title": "Modalités exécution", + "description": "Les modalités d'exécution du marché public.", + "short_title": null, + "enum": ["Tranches", "Bons de commande", "Marchés subséquents"] + }, + { + "name": "modification_id", + "type": "integer", + "title": "Identifiant modification", + "description": "Identifiant de la modification. 0 = données initiales du marché public, 1 = première modification, etc.", + "short_title": "Id. modification" + }, + { + "name": "montant", + "type": "number", + "title": "Montant attribué", + "description": "Montant forfaitaire ou montant maximum estimé hors-taxes, en euros. Ce montant est le montant attribué. Le montant final payé aux titulaires peut évoluer lors de la signature du contrat et de l'exécution du marché.", + "short_title": "Montant" + }, + { + "name": "nature", + "type": "string", + "title": "Nature", + "description": "Marché, Marché de partenariat ou Marché de sécurité.", + "short_title": null + }, + { + "name": "objet", + "type": "string", + "title": "Objet", + "description": "Objet du marché public. Potentiellement coupé à 256 ou 1 000 caractères par le producteur de données.", + "short_title": null + }, + { + "name": "offresRecues", + "type": "integer", + "title": "Offres reçues", + "description": "Le nombre d'offres reçues pendant la phase d'appel d'offres. Comprend aussi les offres irrégulières, inacceptables, inappropriées et anormalement basses.", + "short_title": null + }, + { + "name": "origineFrance", + "type": "number", + "title": "Origine France", + "description": "Pour les marchés de fournitures de denrées alimentaires, de véhicules, de produits de santé et d'habillement, selon la liste annexée à l'arrêté du 22 décembre 2022, la part des produits français avec laquelle le marché sera exécuté. 0.2 = 20 % de la part des produits sont français. Cette valeur ne peut pas être supérieure à la valeur de origineUE.", + "short_title": null + }, + { + "name": "origineUE", + "type": "number", + "title": "Origine UE", + "description": "Pour les marchés de fournitures de denrées alimentaires, de véhicules, de produits de santé et d'habillement, selon la liste annexée à l'arrêté du 22 décembre 2022, la part des produits issus de l'Union européenne avec laquelle le marché sera exécuté. 0.2 = 20 % de la part des produits provient de l'Union européenne. Cette valeur ne peut pas être inférieure à la valeur de origineFrance.", + "short_title": null + }, + { + "name": "procedure", + "type": "string", + "title": "Procédure", + "description": "Le type de procédure utilisé pour le marché public.", + "short_title": null, + "enum": [ + "Procédure négociée ouverte", + "Procédure non négociée ouverte", + "Procédure négociée restreinte", + "Procédure non négociée restreinte" + ] + }, + { + "name": "sourceDataset", + "type": "string", + "title": "Source dataset", + "description": "Code du jeu de données dont proviennent les données de ce marché public.", + "short_title": null + }, + { + "name": "sourceFile", + "type": "string", + "title": "Source fichier", + "description": "Lien vers le fichier de données ouvertes dont proviennent les données de ce marché public.", + "short_title": null, + "format": "uri" + }, + { + "name": "sousTraitanceDeclaree", + "type": "boolean", + "title": "Sous-traitance déclarée", + "description": "Au moment de la notification du marché, les titulaires du marché ont déclaré s'appuyer sur un ou plusieurs sous-traitants pour ce marché public.", + "short_title": "Sous-traitance" + }, + { + "name": "tauxAvance", + "type": "number", + "title": "Taux avance", + "description": "Taux de l'avance attribuée au titulaire principal du marché public par rapport au montant du marché (O.1 = 10 % du montant du marché). En fonction de la valeur de attributionAvance, une valeur égale à 0 signifie qu'il y a une avance mais que le taux n'est pas connu (attributionAvance=true).", + "short_title": null + }, + { + "name": "techniques", + "type": "string", + "title": "Techniques", + "description": "Les techniques d'achat utilisées pour le marché public.", + "short_title": null, + "enum": [ + "Accord-cadre", + "Concours", + "Système de qualification", + "Système d'acquisition dynamique", + "Catalogue électronique", + "Enchère électronique" + ] + }, + { + "name": "titulaire_categorie", + "type": "string", + "title": "Catégorie du titulaire", + "description": "Catégorie de l'entreprise titulaire selon la classification de l'INSEE.", + "short_title": "Catégorie titulaire", + "enum": ["PME", "ETI", "GE"] + }, + { + "name": "titulaire_commune_code", + "type": "string", + "title": "Commune du titulaire (code)", + "description": "Code de la commune où se trouve le titulaire.", + "short_title": "Commune tit. (code)" + }, + { + "name": "titulaire_commune_nom", + "type": "string", + "title": "Commune du titulaire", + "description": "Nom de la commune où se trouve le titulaire.", + "short_title": "Commune titulaire" + }, + { + "name": "titulaire_departement_code", + "type": "string", + "title": "Département du titulaire (code)", + "description": "Code du département où se trouve le titulaire.", + "short_title": "Département tit. (code)" + }, + { + "name": "titulaire_departement_nom", + "type": "string", + "title": "Département du titulaire", + "description": "Nom du département où se trouve le titulaire.", + "short_title": "Département titulaire" + }, + { + "name": "titulaire_distance", + "type": "integer", + "title": "Distance acheteur-titulaire", + "description": "Distance en kilomètres entre l'adresse de l'acheteur et celle du titulaire.", + "short_title": "Distance" + }, + { + "name": "titulaire_id", + "type": "integer", + "title": "Identifiant titulaire", + "description": "Identifiant du titulaire du marché. Voir 'Type identifiant' pour le référentiel utilisé", + "short_title": "Id. titulaire" + }, + { + "name": "titulaire_latitude", + "type": "number", + "title": "Latitude du titulaire", + "description": "Latitude des coordonnées géographiques du titulaire.", + "short_title": "Latitude titulaire" + }, + { + "name": "titulaire_longitude", + "type": "number", + "title": "Longitude du titulaire", + "description": "Longitude des coordonnées géographiques du titulaire.", + "short_title": "Longitude titulaire" + }, + { + "name": "titulaire_nom", + "type": "string", + "title": "Nom titulaire", + "description": "Nom du titulaire. Nom tel que renseigné dans la base SIRENE de l'INSEE si c'est un SIRET.", + "short_title": "Titulaire" + }, + { + "name": "titulaire_region_code", + "type": "string", + "title": "Région du titulaire (code)", + "description": "Code de la région où se trouve le titulaire.", + "short_title": "Région tit. (code)" + }, + { + "name": "titulaire_region_nom", + "type": "string", + "title": "Région du titulaire", + "description": "Nom de la région où se trouve le titulaire.", + "short_title": "Région titulaire" + }, + { + "name": "titulaire_typeIdentifiant", + "type": "string", + "title": "Type identifiant", + "description": "Référentiel utilisé pour l'identifiant du titulaire.", + "short_title": "Type id.", + "enum": ["SIRET", "TVA", "TAHITI", "RIDET", "FRWF", "IREP", "HORS-UE"] + }, + { + "name": "type", + "type": "string", + "title": "Type", + "description": "Type de marché public : fournitures, services ou travaux (dérivé du code CPV).", + "short_title": "Type", + "enum": ["Fournitures", "Services", "Travaux"] + }, + { + "name": "typeGroupementOperateurs", + "type": "string", + "title": "Type groupement", + "description": "Le type de groupement d'entreprises ou d'opérateurs économiques.", + "short_title": "Groupement", + "enum": ["Conjoint", "Solidaire"] + }, + { + "name": "typesPrix", + "type": "string", + "title": "Types prix", + "description": "Les types de prix du marché public.", + "short_title": null, + "enum": [ + "Définitif ferme", + "Définitif actualisable", + "Définitif révisable", + "Provisoire" + ] + }, + { + "name": "uid", + "type": "string", + "title": "Identifiant unique", + "description": "Concaténation du SIRET de l'acheteur (acheteur_id) et de l'identifiant interne de l'acheteur (id). Utilisé comme identifiant de marché unique au niveau national.", + "short_title": "Id. unique" + } + ] +} diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..75a214a --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,77 @@ +import json + +import httpx +import pytest + +from src.utils import data as data_mod + +VALID = {"fields": [{"name": "uid", "title": "UID"}, {"name": "objet"}]} + + +class FakeResp: + def __init__(self, payload, ok=True, bad_json=False): + self._payload = payload + self._ok = ok + self._bad_json = bad_json + + def raise_for_status(self): + if not self._ok: + raise httpx.HTTPError("boom") + return self + + def json(self): + if self._bad_json: + raise json.JSONDecodeError("bad", "", 0) + return self._payload + + +def test_remote_ok_returns_schema_and_writes_cache(tmp_path, monkeypatch): + cache = tmp_path / "schema.cache.json" + monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x") + monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache)) + monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID)) + result = data_mod.get_data_schema() + assert "uid" in result + assert json.loads(cache.read_text())["fields"][0]["name"] == "uid" + + +def test_remote_http_error_falls_back_to_cache(tmp_path, monkeypatch): + cache = tmp_path / "schema.cache.json" + cache.write_text(json.dumps(VALID)) + monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x") + monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache)) + monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(None, ok=False)) + assert "uid" in data_mod.get_data_schema() + + +def test_remote_malformed_falls_back_to_cache(tmp_path, monkeypatch): + cache = tmp_path / "schema.cache.json" + cache.write_text(json.dumps(VALID)) + monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x") + monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache)) + monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp({"nope": 1})) + assert "uid" in data_mod.get_data_schema() + + +def test_no_url_uses_cache(tmp_path, monkeypatch): + cache = tmp_path / "schema.cache.json" + cache.write_text(json.dumps(VALID)) + monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False) + monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache)) + assert "uid" in data_mod.get_data_schema() + + +def test_no_source_raises(tmp_path, monkeypatch): + monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False) + monkeypatch.setenv("DATA_SCHEMA_CACHE", str(tmp_path / "missing.json")) + with pytest.raises(RuntimeError): + data_mod.get_data_schema() + + +def test_cache_write_failure_is_non_blocking(tmp_path, monkeypatch): + # parent inexistant => l'écriture du cache échoue, mais le schéma est renvoyé + cache = tmp_path / "nodir" / "schema.cache.json" + monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x") + monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache)) + monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID)) + assert "uid" in data_mod.get_data_schema() From 5a6aa31c86e9a91d08fd2217fc23bf8691e17a66 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 13:28:36 +0200 Subject: [PATCH 089/151] =?UTF-8?q?fix:=20robustesse=20sch=C3=A9ma=20?= =?UTF-8?q?=E2=80=94=20TransportError,=20validation=20name,=20ValueError?= =?UTF-8?q?=20cache=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/utils/data.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/utils/data.py b/src/utils/data.py index 339f826..811d7a7 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -65,7 +65,12 @@ def get_departement_region(code_postal: str | None): def _validate_schema(raw) -> dict | None: - if isinstance(raw, dict) and isinstance(raw.get("fields"), list) and raw["fields"]: + if ( + isinstance(raw, dict) + and isinstance(raw.get("fields"), list) + and raw["fields"] + and all(isinstance(c, dict) and "name" in c for c in raw["fields"]) + ): return raw return None @@ -75,7 +80,7 @@ def _fetch_remote_schema(url: str | None) -> dict | None: return None try: raw = get(url, follow_redirects=True).raise_for_status().json() - except (httpx.HTTPError, json.JSONDecodeError) as e: + except (httpx.HTTPError, httpx.TransportError, json.JSONDecodeError) as e: logger.error(f"Schéma distant indisponible ({url}) : {e}") return None return _validate_schema(raw) @@ -101,7 +106,7 @@ def _persist_schema_cache(raw: dict, path: str) -> None: with open(tmp, "w") as f: json.dump(raw, f) os.replace(tmp, path) - except OSError as e: + except (OSError, ValueError) as e: logger.warning(f"Écriture du cache schéma échouée ({path}) : {e}") From bba53d81e1c93af2751af266f5c06363c358669f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 13:48:32 +0200 Subject: [PATCH 090/151] =?UTF-8?q?feat:=20r=C3=A9utiliser=20le=20DuckDB?= =?UTF-8?q?=20existant=20si=20le=20bootstrap=20=C3=A9choue=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/db.py | 19 ++++++++++++---- tests/test_db.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/db.py b/src/db.py index 7fca3de..9e3f24f 100644 --- a/src/db.py +++ b/src/db.py @@ -118,13 +118,24 @@ def _ensure_database() -> Path: db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb")) parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "") lock_path = db_path.with_suffix(".duckdb.lock") + db_exists = db_path.exists() with open(lock_path, "w") as lock_fd: fcntl.flock(lock_fd, fcntl.LOCK_EX) - if should_rebuild(db_path, parquet_path): - build_database(db_path) - else: - logger.debug("Base de données déjà disponible et à jour.") + try: + if should_rebuild(db_path, parquet_path): + build_database(db_path) + else: + logger.debug("Base de données déjà disponible et à jour.") + except Exception as e: + if db_exists: + logger.error( + f"Bootstrap données KO ({e}). " + f"Réutilisation du DuckDB existant : {db_path}" + ) + else: + logger.critical("Aucune base DuckDB et reconstruction impossible.") + raise return db_path diff --git a/tests/test_db.py b/tests/test_db.py index 10e9725..865da05 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -312,3 +312,59 @@ def test_concurrent_build_serialized(tmp_path, monkeypatch): assert errors == [] assert db_path.exists() assert not tmp_path_artifact.exists() + + +def _raise(*args, **kwargs): + raise RuntimeError("boom") + + +def test_ensure_database_reuses_db_when_should_rebuild_raises(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" + dbf.write_bytes(b"existing") + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") + monkeypatch.setattr(db, "should_rebuild", _raise) + result = db._ensure_database() # ne doit pas lever + assert result == dbf + assert dbf.read_bytes() == b"existing" + + +def test_ensure_database_reuses_db_when_build_raises(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" + dbf.write_bytes(b"existing") + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") + monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) + monkeypatch.setattr(db, "build_database", _raise) + db._ensure_database() # ne doit pas lever + assert dbf.read_bytes() == b"existing" + + +def test_ensure_database_raises_on_cold_start(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" # n'existe pas + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") + monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) + monkeypatch.setattr(db, "build_database", _raise) + with pytest.raises(RuntimeError): + db._ensure_database() + + +def test_ensure_database_builds_when_needed(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" + dbf.write_bytes(b"old") + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://x") + called = {} + monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) + monkeypatch.setattr(db, "build_database", lambda p: called.setdefault("built", p)) + db._ensure_database() + assert called.get("built") == dbf From fa2709b38c6291f25b4fdc683e10f211424d4e41 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 13:50:57 +0200 Subject: [PATCH 091/151] =?UTF-8?q?fix:=20re-v=C3=A9rifier=20db=5Fpath.exi?= =?UTF-8?q?sts()=20dans=20le=20handler=20et=20assertion=20return=20value?= =?UTF-8?q?=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/db.py | 2 +- tests/test_db.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/db.py b/src/db.py index 9e3f24f..c4d0892 100644 --- a/src/db.py +++ b/src/db.py @@ -128,7 +128,7 @@ def _ensure_database() -> Path: else: logger.debug("Base de données déjà disponible et à jour.") except Exception as e: - if db_exists: + if db_exists and db_path.exists(): logger.error( f"Bootstrap données KO ({e}). " f"Réutilisation du DuckDB existant : {db_path}" diff --git a/tests/test_db.py b/tests/test_db.py index 865da05..56dee54 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -340,7 +340,8 @@ def test_ensure_database_reuses_db_when_build_raises(tmp_path, monkeypatch): monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) monkeypatch.setattr(db, "build_database", _raise) - db._ensure_database() # ne doit pas lever + result = db._ensure_database() # ne doit pas lever + assert result == dbf assert dbf.read_bytes() == b"existing" From cb93dbe05a6bdcc98275da8737307ebd749abc26 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 13:58:50 +0200 Subject: [PATCH 092/151] feat: chargements de pages best-effort au boot (tableau, sources) (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/figures.py | 8 ++++-- src/pages/tableau.py | 15 +++++++--- src/utils/__init__.py | 16 +++++++++++ tests/test_page_loads.py | 61 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 tests/test_page_loads.py diff --git a/src/figures.py b/src/figures.py index 4670723..966fd9b 100644 --- a/src/figures.py +++ b/src/figures.py @@ -1,6 +1,5 @@ from datetime import datetime from typing import Literal -from urllib.error import HTTPError, URLError import dash_bootstrap_components as dbc import dash_leaflet as dl @@ -120,9 +119,12 @@ def get_barchart_sources(lff: pl.LazyFrame, type_date: str): def get_sources_tables(source_path) -> html.Div: try: + if not source_path: + raise ValueError("SOURCE_STATS_CSV_PATH non défini") dff = pl.read_csv(source_path) - except (URLError, HTTPError): - return html.Div("Erreur de connexion") + except Exception as e: + logger.warning(f"Sources de données indisponibles ({e})") + return html.Div("Sources de données momentanément indisponibles.") dff = dff.with_columns( ( pl.lit(' float | None: + """Date de MAJ des données, best-effort, sans jamais lever (usage au boot).""" + try: + return get_last_modified(parquet_path) + except Exception as e: + logger.warning(f"Date de mise à jour des données indisponible ({e})") + if fallback_path: + try: + return os.path.getmtime(fallback_path) + except OSError: + pass + return None diff --git a/tests/test_page_loads.py b/tests/test_page_loads.py new file mode 100644 index 0000000..039090a --- /dev/null +++ b/tests/test_page_loads.py @@ -0,0 +1,61 @@ +import os + + +def test_update_timestamp_falls_back_to_db_mtime(tmp_path, monkeypatch): + import src.utils as u + from src.utils import get_data_update_timestamp + + def boom(*a, **k): + raise RuntimeError("net down") + + monkeypatch.setattr(u, "get_last_modified", boom) + fb = tmp_path / "decp.duckdb" + fb.write_bytes(b"x") + assert get_data_update_timestamp("http://x", str(fb)) == os.path.getmtime(str(fb)) + + +def test_update_timestamp_none_when_all_fail(monkeypatch): + import src.utils as u + from src.utils import get_data_update_timestamp + + def boom(*a, **k): + raise RuntimeError("net down") + + monkeypatch.setattr(u, "get_last_modified", boom) + assert get_data_update_timestamp("http://x", None) is None + + +def test_update_timestamp_nominal(monkeypatch): + import src.utils as u + from src.utils import get_data_update_timestamp + + monkeypatch.setattr(u, "get_last_modified", lambda p: 123.0) + assert get_data_update_timestamp("http://x", None) == 123.0 + + +def test_sources_tables_none_path(): + from src.figures import get_sources_tables + + div = get_sources_tables(None) + assert "indisponible" in str(div.children).lower() + + +def test_sources_tables_missing_file(): + from src.figures import get_sources_tables + + div = get_sources_tables("/does/not/exist.csv") + assert "indisponible" in str(div.children).lower() + + +def test_sources_tables_valid_csv(tmp_path): + from dash import dash_table + + from src.figures import get_sources_tables + + csv = tmp_path / "s.csv" + csv.write_text( + "nom,organisation,nb_marchés,nb_acheteurs,code,url,unique\n" + "Source A,Org A,5,2,XA,http://a,1\n" + ) + div = get_sources_tables(str(csv)) + assert isinstance(div.children, dash_table.DataTable) From b8d73c7f7b52ffbeafa058444c3ce8a31bc95041 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 17:58:23 +0200 Subject: [PATCH 093/151] fix: omettre temporalCoverage si date inconnue + TimeoutException explicite (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/pages/tableau.py | 6 +++++- src/utils/data.py | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 805dcb0..c6ef4f4 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -124,7 +124,11 @@ layout = [ "contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432", }, ], - "temporalCoverage": f"2018-01-01/{update_date_iso[:10]}", + **( + {"temporalCoverage": f"2018-01-01/{update_date_iso[:10]}"} + if update_date_iso + else {} + ), "spatialCoverage": { "@type": "Place", "address": {"countryCode": "FR"}, diff --git a/src/utils/data.py b/src/utils/data.py index 811d7a7..58a9637 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -80,7 +80,12 @@ def _fetch_remote_schema(url: str | None) -> dict | None: return None try: raw = get(url, follow_redirects=True).raise_for_status().json() - except (httpx.HTTPError, httpx.TransportError, json.JSONDecodeError) as e: + except ( + httpx.HTTPError, + httpx.TransportError, + httpx.TimeoutException, + json.JSONDecodeError, + ) as e: logger.error(f"Schéma distant indisponible ({url}) : {e}") return None return _validate_schema(raw) From fede22f3149b23ff588baa6e945e495f5d4cf98f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 16 Jun 2026 11:06:46 +0200 Subject: [PATCH 094/151] Plus d'options dans swagger UI pour Try it out #78 --- src/api/__init__.py | 8 ++++++++ src/api/routes.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/api/__init__.py b/src/api/__init__.py index e68f48b..e9b1de4 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -15,6 +15,14 @@ def init_api(server) -> None: "OPENAPI_SWAGGER_UI_URL", "https://cdn.jsdelivr.net/npm/swagger-ui-dist/", ) + server.config.setdefault( + "API_SPEC_OPTIONS", + { + "components": { + "securitySchemes": {"BearerAuth": {"type": "http", "scheme": "bearer"}} + } + }, + ) api = Api(server) api.register_blueprint(routes.bp) diff --git a/src/api/routes.py b/src/api/routes.py index 9ffa0aa..7ac68d3 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -85,6 +85,7 @@ def health(): @bp.route("/schema") +@bp.doc(security=[{"BearerAuth": []}]) @require_token def schema(): """Liste des colonnes disponibles dans le dataset DECP.""" @@ -93,6 +94,46 @@ def schema(): @bp.route("/data") +@bp.doc( + security=[{"BearerAuth": []}], + parameters=[ + { + "name": "page", + "in": "query", + "schema": {"type": "integer", "default": 1, "minimum": 1}, + "description": "Numéro de page (commence à 1).", + }, + { + "name": "page_size", + "in": "query", + "schema": {"type": "integer", "default": 50, "minimum": 1, "maximum": 1000}, + "description": "Nombre de résultats par page (max 1000).", + }, + { + "name": "columns", + "in": "query", + "schema": {"type": "string"}, + "description": "Liste de colonnes à retourner, séparées par des virgules (ex: `id,acheteur_id,montant`). Par défaut : toutes.", + }, + { + "name": "count", + "in": "query", + "schema": {"type": "string", "enum": ["true", "false"], "default": "true"}, + "description": "Inclure le total (`COUNT(*)`) dans la réponse. Mettre `false` pour accélérer la requête.", + }, + { + "name": "__", + "in": "query", + "schema": {"type": "string"}, + "description": ( + "Filtre dynamique. Remplacer `` par un nom de colonne (voir `/schema`) " + "et `` par : `exact`, `contains`, `notcontains`, `less`, `greater`, " + "`strictly_less`, `strictly_greater`, `in`, `notin`, `isnull`, `isnotnull`, `sort`. " + "Exemple : `acheteur_id__contains=VILLE`, `montant__greater=10000`." + ), + }, + ], +) @require_token def data(): """Récupère des marchés publics filtrés. From ffaf200c0735f34524d1d4a84cb32b4dfc281fb8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 16 Jun 2026 17:03:14 +0200 Subject: [PATCH 095/151] api/v1/schema n'est plus auth, schema renvoie table schema, adaptation des tests #78 --- src/api/routes.py | 8 +++----- tests/api/test_endpoints_schema.py | 21 ++++++++++----------- tests/api/test_tracking.py | 6 +++--- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/api/routes.py b/src/api/routes.py index 7ac68d3..418492d 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -6,6 +6,7 @@ from src.api.auth import require_token from src.api.filters import FilterError, build_where from src.db import count_marches, query_marches from src.db import schema as duckdb_schema +from src.utils.data import DATA_SCHEMA bp = Blueprint( "api_v1", @@ -85,12 +86,9 @@ def health(): @bp.route("/schema") -@bp.doc(security=[{"BearerAuth": []}]) -@require_token def schema(): - """Liste des colonnes disponibles dans le dataset DECP.""" - cols = [{"name": name, "type": str(dtype)} for name, dtype in duckdb_schema.items()] - return {"columns": cols} + """Liste des champs disponibles dans le dataset DECP (format TableSchema).""" + return {"fields": list(DATA_SCHEMA.values())} @bp.route("/data") diff --git a/tests/api/test_endpoints_schema.py b/tests/api/test_endpoints_schema.py index 3d8438e..2cab204 100644 --- a/tests/api/test_endpoints_schema.py +++ b/tests/api/test_endpoints_schema.py @@ -1,19 +1,18 @@ -def test_schema_without_token_returns_401(api_client): +def test_schema_accessible_without_token(api_client): client, _ = api_client resp = client.get("/api/v1/schema") - assert resp.status_code == 401 + assert resp.status_code == 200 -def test_schema_returns_columns(api_client, valid_token_header): +def test_schema_returns_fields(api_client): client, _ = api_client - resp = client.get("/api/v1/schema", headers=valid_token_header) + resp = client.get("/api/v1/schema") assert resp.status_code == 200 data = resp.get_json() - assert "columns" in data - assert isinstance(data["columns"], list) - assert len(data["columns"]) > 0 - first = data["columns"][0] - assert set(first.keys()) >= {"name", "type"} - # uid doit être présent dans le schéma DECP de test - names = [c["name"] for c in data["columns"]] + assert "fields" in data + assert isinstance(data["fields"], list) + assert len(data["fields"]) > 0 + first = data["fields"][0] + assert set(first.keys()) >= {"name", "type", "title", "description"} + names = [f["name"] for f in data["fields"]] assert "uid" in names diff --git a/tests/api/test_tracking.py b/tests/api/test_tracking.py index 36e57a6..9a26ab5 100644 --- a/tests/api/test_tracking.py +++ b/tests/api/test_tracking.py @@ -29,7 +29,7 @@ def test_after_request_hook_increments_counter_async(api_client, valid_token_hea # Faire une requête (qui doit déclencher l'incrément) client.get("/api/v1/health") # pas authentifiée → ne compte pas - client.get("/api/v1/schema", headers=valid_token_header) + client.get("/api/v1/data", headers=valid_token_header) # /schema est public tracking.flush(timeout=2.0) @@ -49,7 +49,7 @@ def test_matomo_disabled_skips_call(monkeypatch, api_client, valid_token_header) "_post_matomo", lambda **kw: called.append(kw), ) - client.get("/api/v1/health") + client.get("/api/v1/data", headers=valid_token_header) # authentifiée → hook actif tracking.flush(timeout=2.0) assert called == [] @@ -69,7 +69,7 @@ def test_matomo_enabled_posts_event(monkeypatch, api_client, valid_token_header) ) client, _ = api_client - client.get("/api/v1/schema", headers=valid_token_header) + client.get("/api/v1/data", headers=valid_token_header) # /schema est public tracking.flush(timeout=2.0) assert len(captured) == 1 From 441d36273afa708c28d4050797844b733a6e7f67 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 13:22:49 +0200 Subject: [PATCH 096/151] =?UTF-8?q?docs:=20spec=20parit=C3=A9=20API=20decp?= =?UTF-8?q?.info=20/=20tabular-api=20data.gouv.fr=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opérateurs manquants : differs + agrégation (groupby/count/sum/avg/min/max), renommage du param réservé count -> count_results. or hors périmètre. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-22-api-parite-datagouv-design.md | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-api-parite-datagouv-design.md diff --git a/docs/superpowers/specs/2026-06-22-api-parite-datagouv-design.md b/docs/superpowers/specs/2026-06-22-api-parite-datagouv-design.md new file mode 100644 index 0000000..146550c --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-api-parite-datagouv-design.md @@ -0,0 +1,189 @@ +# Parité de l'API decp.info avec tabular-api (data.gouv.fr) — opérateurs manquants + +**Date :** 2026-06-22 +**Périmètre :** `count_results` + `differs` + suite d'agrégation. **Hors périmètre :** le paramètre réservé `or` (itération dédiée ultérieure). + +## Contexte + +L'API `/api/v1/data` de decp.info reproduit le schéma de requête de +`tabular-api` (`datagouv/api-tabular`), qui sert la même donnée DECP sur +data.gouv.fr. L'objectif est d'atteindre la parité sur les **opérateurs** +de filtrage/agrégation, pour qu'une requête écrite pour data.gouv.fr +fonctionne à l'identique sur decp.info. + +Source faisant autorité du comportement cible : `api_tabular/core/query.py` +du dépôt `datagouv/api-tabular`. Tous les comportements ci-dessous ont été +vérifiés en direct contre la ressource DECP +`22847056-61df-452d-837d-8b8ceadbfc52`. + +### Écart constaté + +Opérateurs présents chez data.gouv.fr et absents de decp.info : + +| Mot-clé | Nature | +| ----------------------------------- | ----------------------------------- | +| `differs` | opérateur de filtre | +| `groupby` | drapeau d'agrégation (sans valeur) | +| `count`, `sum`, `avg`, `min`, `max` | drapeaux d'agrégation (sans valeur) | + +De plus, le paramètre réservé `count=true|false` de decp.info entre en +collision avec l'opérateur d'agrégation `count` de data.gouv.fr. + +État courant pertinent : + +- `src/api/filters.py` : `OPERATORS`, `RESERVED_PARAMS`, `build_where()`. +- `src/api/routes.py` : route `data()`, doc swagger des paramètres. +- `src/db.py` : `query_marches()`, `count_marches()`. + +## Objectifs + +1. Renommer le paramètre réservé `count` → `count_results` (valeurs + `true|false`, défaut `true`), libérant `count` comme opérateur. +2. Ajouter l'opérateur de filtre `differs`. +3. Ajouter les opérateurs d'agrégation `groupby`, `count`, `sum`, `avg`, + `min`, `max`, avec la même forme de réponse que data.gouv.fr. + +Non-objectifs : le paramètre `or` (grammaire récursive imbriquée), les +opérateurs `groupby`/agrégats appliqués via `or`, toute évolution du +benchmark (sera traitée après). + +## Conception + +### 1. Renommage `count` → `count_results` + +- `RESERVED_PARAMS` : `{"page", "page_size", "columns", "count_results"}`. +- `routes.data()` : lire `request.args.get("count_results", "true")`. +- Doc swagger : remplacer le paramètre `count` par `count_results`, même + description (« inclure le total `COUNT(*)` ; `false` pour accélérer »). +- Le mot `count` n'est donc plus réservé ; il est interprété comme + opérateur d'agrégation (section 3). + +**Rupture de contrat assumée :** un client qui passait `count=false` +verra ce paramètre ré-interprété. Acceptable : l'API est privée et +l'objectif est explicitement la parité avec data.gouv.fr. À documenter +dans le message de commit. + +### 2. Opérateur `differs` + +Sémantique data.gouv.fr : `col__differs=val` → PostgREST `isdistinct`, soit +`IS DISTINCT FROM` (≠ null-safe : `NULL differs 44` est vrai). + +- Ajouter `"differs"` à `OPERATORS`. +- Dans `build_where()`, après coercition de la valeur : + `where_parts.append('"col" IS DISTINCT FROM ?')` ; `params.append(v)`. +- DuckDB supporte nativement `IS DISTINCT FROM`. + +### 3. Opérateurs d'agrégation + +#### Forme des requêtes (vérifiée) + +Drapeaux **sans valeur** dans la query string : +`?acheteur_departement_code__groupby&uid__count&montant__sum&montant__avg&montant__min&montant__max` + +Une valeur (`__groupby=1`) est un cas d'erreur côté data.gouv.fr ; on +n'impose pas cette stricte interdiction mais on accepte la forme sans +valeur (Werkzeug fournit alors la valeur `""`). + +Opérateurs : `groupby`, `count`, `sum`, `avg`, `min`, `max`. + +#### Forme de la réponse (vérifiée) + +``` +SELECT , FN("") AS "__", ... +FROM decp +WHERE +GROUP BY +LIMIT OFFSET +``` + +- Colonnes de sortie : la colonne `groupby` garde son nom ; chaque agrégat + est nommé `"__"` (ex. `uid__count`, `montant__sum`). +- `meta` : `{"page", "page_size"}` **sans `total`** (data.gouv.fr n'en + renvoie pas en mode agrégation). `count_results` est ignoré dans ce mode. +- Pas de tri par défaut. +- Les filtres `WHERE` (y compris `differs`) restent appliqués. + +#### Contraintes répliquées + +- `columns` + agrégation → erreur 400 (`columns ne peut pas être combiné avec des agrégateurs`). Vérifié identique chez data.gouv.fr. +- Un agrégat (`count`/`sum`/…) sans `groupby` est autorisé (agrégat global, + une ligne). + +#### Architecture + +Nouvelle fonction de parsing dans `src/api/filters.py` : + +``` +parse_aggregators(args, schema) -> AggregationSpec | None +``` + +- Retourne `None` si aucun opérateur d'agrégation présent → la route suit + le chemin existant. +- Sinon retourne les colonnes `groupby` et la liste des agrégats + `(fonction_sql, colonne, alias)`. +- Valide que chaque colonne existe dans le schéma ; opérateur inconnu → + `FilterError`. + +`build_where()` est inchangé pour WHERE/ORDER ; il continue d'ignorer les +clés réservées et **doit ignorer les drapeaux d'agrégation** (ne pas les +traiter comme des filtres). Comme les drapeaux d'agrégation arrivent comme +`(col__op, "")`, et que `op` ∈ agrégateurs, `build_where` les saute. + +Nouvelle fonction dans `src/db.py` : + +``` +aggregate_marches(select_sql, where_sql, params, group_by, limit, offset) -> pl.DataFrame +``` + +- `select_sql` et `group_by` sont des fragments SQL construits depuis des + noms de colonnes validés contre le schéma (jamais de valeur utilisateur + libre) ; les valeurs de filtre passent par le binding `?`. + +Orchestration dans `routes.data()` : + +``` +agg = parse_aggregators(args, schema) +where_sql, params, order_sql = build_where(args, schema) # filtres seuls +if agg: + if columns: -> abort(400) + df = aggregate_marches(agg.select_sql, where_sql, params, agg.group_by, page_size, offset) + meta = {"page", "page_size"} # pas de total +else: + +``` + +### Sécurité SQL + +Les noms de colonnes proviennent du schéma DuckDB validé (`col in schema`), +jamais interpolés depuis une valeur arbitraire ; les fonctions d'agrégation +sont une liste blanche fixe (`COUNT/SUM/AVG/MIN/MAX`). Les valeurs de +filtre restent liées par paramètres `?`. Aucun chemin n'interpole de valeur +utilisateur dans le SQL. + +## Tests + +Tests existants à adapter (renommage `count` → `count_results`). + +Nouveaux tests (`tests/` API) : + +- `differs` : exclut les lignes égales, inclut les NULL. +- agrégation `groupby` + `count` : nombre de groupes, noms de colonnes + `col__count`. +- agrégation multiple `groupby`+`count`+`sum`+`avg`+`min`+`max` : alias et + types corrects, `meta` sans `total`. +- agrégat sans `groupby` : une ligne. +- `groupby` + filtre `WHERE` : le filtre s'applique avant l'agrégation. +- `columns` + agrégation : 400. +- `count_results=false` : réponse sans `total` (chemin non-agrégé). +- non-régression : opérateurs existants inchangés. + +Comparaison de référence : pour quelques requêtes, les valeurs agrégées +doivent correspondre à celles renvoyées par data.gouv.fr sur la même +ressource (aux différences de fraîcheur de données près). + +## Gestion des erreurs + +- Opérateur inconnu, colonne inconnue → `FilterError` → 400 (existant). +- `columns` + agrégation → 400 avec message explicite. +- Valeur non coercible pour `differs` → `FilterError` (existant via + `_coerce`). From b0fbe89c2c7f6c9fc08d0d976cc123293833ef10 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 13:27:08 +0200 Subject: [PATCH 097/151] =?UTF-8?q?docs:=20ajoute=20la=20doc=20Swagger=20d?= =?UTF-8?q?es=20mots-cl=C3=A9s=20au=20spec=20parit=C3=A9=20API=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../2026-06-22-api-parite-datagouv-design.md | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-06-22-api-parite-datagouv-design.md b/docs/superpowers/specs/2026-06-22-api-parite-datagouv-design.md index 146550c..46d6068 100644 --- a/docs/superpowers/specs/2026-06-22-api-parite-datagouv-design.md +++ b/docs/superpowers/specs/2026-06-22-api-parite-datagouv-design.md @@ -42,6 +42,8 @@ collision avec l'opérateur d'agrégation `count` de data.gouv.fr. 2. Ajouter l'opérateur de filtre `differs`. 3. Ajouter les opérateurs d'agrégation `groupby`, `count`, `sum`, `avg`, `min`, `max`, avec la même forme de réponse que data.gouv.fr. +4. **Documenter** chaque mot-clé dans le Swagger UI de l'API, de façon à + mettre en valeur les possibilités de l'API decp.info. Non-objectifs : le paramètre `or` (grammaire récursive imbriquée), les opérateurs `groupby`/agrégats appliqués via `or`, toute évolution du @@ -58,10 +60,9 @@ benchmark (sera traitée après). - Le mot `count` n'est donc plus réservé ; il est interprété comme opérateur d'agrégation (section 3). -**Rupture de contrat assumée :** un client qui passait `count=false` -verra ce paramètre ré-interprété. Acceptable : l'API est privée et -l'objectif est explicitement la parité avec data.gouv.fr. À documenter -dans le message de commit. +**Rupture de contrat :** un client qui passait `count=false` verra ce +paramètre ré-interprété. Sans conséquence : l'API n'est pas encore en +production, on peut donc itérer librement. ### 2. Opérateur `differs` @@ -152,6 +153,33 @@ else: ``` +### 4. Documentation (Swagger UI) + +La doc de l'API est générée par flask-smorest et exposée sur +`/api/v1/swagger`, pilotée par le docstring de `routes.data()` et le bloc +`@bp.doc(parameters=[...])`. C'est la surface de documentation à enrichir +(aucune autre page de doc API n'existe). + +À mettre à jour : + +- Remplacer le paramètre `count` par `count_results` (même description). +- Étendre la description du paramètre dynamique `__` + avec une **définition d'une ligne par opérateur**, regroupés par + catégorie : + - _Filtres_ : `exact`, `differs`, `contains`, `notcontains`, `in`, + `notin`, `less`, `greater`, `strictly_less`, `strictly_greater`, + `isnull`, `isnotnull`, `sort`. + - _Agrégation_ (drapeaux sans valeur) : `groupby`, `count`, `sum`, + `avg`, `min`, `max`. +- Décrire le **mode agrégation** : drapeaux sans valeur, réponse en lignes + groupées, colonnes `col__op`, `columns` interdit, pas de `total`. +- Mettre à jour le docstring de `data()` (visible dans Swagger) en + cohérence, avec au moins un exemple de requête d'agrégation. + +Objectif éditorial : un lecteur qui découvre l'API doit comprendre, depuis +le seul Swagger UI, l'ensemble des opérateurs disponibles et comment s'en +servir. + ### Sécurité SQL Les noms de colonnes proviennent du schéma DuckDB validé (`col in schema`), @@ -176,6 +204,9 @@ Nouveaux tests (`tests/` API) : - `columns` + agrégation : 400. - `count_results=false` : réponse sans `total` (chemin non-agrégé). - non-régression : opérateurs existants inchangés. +- doc : le spec OpenAPI généré (`/api/v1/openapi.json`) référence + `count_results` et mentionne les nouveaux opérateurs (vérif légère, p. ex. + présence des chaînes attendues). Comparaison de référence : pour quelques requêtes, les valeurs agrégées doivent correspondre à celles renvoyées par data.gouv.fr sur la même From 5166c88b8b049071d180b18d7b99d872a72bcdae Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 13:35:34 +0200 Subject: [PATCH 098/151] =?UTF-8?q?docs:=20plan=20d'impl=C3=A9mentation=20?= =?UTF-8?q?parit=C3=A9=20API=20decp.info=20/=20tabular-api=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-22-api-parite-datagouv.md | 725 ++++++++++++++++++ 1 file changed, 725 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-api-parite-datagouv.md diff --git a/docs/superpowers/plans/2026-06-22-api-parite-datagouv.md b/docs/superpowers/plans/2026-06-22-api-parite-datagouv.md new file mode 100644 index 0000000..254967c --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-api-parite-datagouv.md @@ -0,0 +1,725 @@ +# Parité API decp.info / tabular-api — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Atteindre la parité de l'API `/api/v1/data` de decp.info avec `tabular-api` (data.gouv.fr) sur les opérateurs manquants, et documenter chaque mot-clé dans le Swagger UI. + +**Architecture:** Le parsing des filtres reste dans `src/api/filters.py` (`build_where`). On y ajoute l'opérateur `differs` et une nouvelle fonction `parse_aggregators` qui détecte les drapeaux d'agrégation et produit des fragments SQL. `src/db.py` gagne `aggregate_marches`. `src/api/routes.py` oriente vers le chemin agrégation ou le chemin normal, renomme le param réservé `count`→`count_results`, et documente les opérateurs. + +**Tech Stack:** Flask + flask-smorest, DuckDB, Polars, pytest. + +## Global Constraints + +- Imports internes toujours préfixés `src.` (ex. `src.api.filters`). +- Valeurs de filtre liées par paramètres `?` (jamais interpolées). Noms de colonnes validés contre `schema` avant interpolation. +- Spec de référence : `docs/superpowers/specs/2026-06-22-api-parite-datagouv-design.md`. +- Périmètre : `count_results`, `differs`, agrégation (`groupby`/`count`/`sum`/`avg`/`min`/`max`), doc. **Hors périmètre : `or`.** +- Lancer les tests via `rtk pytest` ; l'environnement de test définit `DEVELOPMENT=true` et `DATA_FILE_PARQUET_PATH=tests/test.parquet`. +- `tests/test.parquet` contient notamment les colonnes : `uid`, `montant` (Int64), `acheteur_departement_code`, `objet`, `dateNotification` (Date). + +--- + +### Task 1 : Renommer le param réservé `count` → `count_results` + +**Files:** + +- Modify: `src/api/filters.py` (constante `RESERVED_PARAMS`) +- Modify: `src/api/routes.py` (lecture du param + doc swagger) +- Test: `tests/api/test_filters.py`, `tests/api/test_endpoints_data.py` + +**Interfaces:** + +- Consumes: rien. +- Produces: le param réservé s'appelle désormais `count_results` ; `count` n'est plus réservé (libéré pour l'agrégation en Task 3). + +- [ ] **Step 1 : Mettre à jour les tests existants** + +Dans `tests/api/test_filters.py`, remplacer `("count", "false")` par `("count_results", "false")` dans `test_reserved_params_are_ignored` : + +```python +def test_reserved_params_are_ignored(): + where, params, order = build_where( + [ + ("page", "2"), + ("page_size", "100"), + ("columns", "uid"), + ("count_results", "false"), + ("uid__exact", "z"), + ], + SCHEMA, + ) + assert where == '"uid" = ?' + assert params == ["z"] +``` + +Dans `tests/api/test_endpoints_data.py`, renommer le test et l'URL : + +```python +def test_data_count_results_false_omits_total(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data?count_results=false", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert "total" not in body["meta"] +``` + +- [ ] **Step 2 : Lancer les tests, vérifier l'échec** + +Run: `rtk pytest tests/api/test_endpoints_data.py::test_data_count_results_false_omits_total tests/api/test_filters.py::test_reserved_params_are_ignored -v` +Expected: FAIL (`count_results` encore traité comme filtre inconnu → 400 / `FilterError`). + +- [ ] **Step 3 : Modifier `RESERVED_PARAMS`** + +Dans `src/api/filters.py` : + +```python +RESERVED_PARAMS = {"page", "page_size", "columns", "count_results"} +``` + +- [ ] **Step 4 : Modifier la lecture dans la route** + +Dans `src/api/routes.py`, fonction `data()`, remplacer : + +```python + count = request.args.get("count", "true").lower() != "false" +``` + +par : + +```python + count_results = request.args.get("count_results", "true").lower() != "false" +``` + +et plus bas remplacer `if count else None` par `if count_results else None`. + +- [ ] **Step 5 : Mettre à jour la doc swagger du param** + +Dans `src/api/routes.py`, dans `@bp.doc(parameters=[...])`, remplacer le bloc du paramètre `count` par : + +```python + { + "name": "count_results", + "in": "query", + "schema": {"type": "string", "enum": ["true", "false"], "default": "true"}, + "description": "Inclure le total (`COUNT(*)`) dans `meta`. Mettre `false` pour accélérer la requête. Ignoré en mode agrégation.", + }, +``` + +Et dans le docstring de `data()`, remplacer la mention `count (true|false ...)` par `count_results (true|false ; mettre false pour économiser le COUNT(*))`. + +- [ ] **Step 6 : Lancer les tests, vérifier le succès** + +Run: `rtk pytest tests/api/test_endpoints_data.py tests/api/test_filters.py -v` +Expected: PASS (tous). + +- [ ] **Step 7 : Commit** + +```bash +git add src/api/filters.py src/api/routes.py tests/api/test_filters.py tests/api/test_endpoints_data.py +git commit -m "feat(api): renomme le param réservé count en count_results (#78)" +``` + +--- + +### Task 2 : Opérateur de filtre `differs` + +**Files:** + +- Modify: `src/api/filters.py` (`OPERATORS`, `build_where`) +- Test: `tests/api/test_filters.py`, `tests/api/test_endpoints_data.py` + +**Interfaces:** + +- Consumes: `build_where(args, schema) -> (where_sql, params, order_sql)` (existant). +- Produces: `col__differs=val` → fragment SQL `"col" IS DISTINCT FROM ?`. + +- [ ] **Step 1 : Écrire les tests unitaires (échec attendu)** + +Dans `tests/api/test_filters.py` : + +```python +def test_differs_filter(): + where, params, _ = build_where([("uid__differs", "abc")], SCHEMA) + assert where == '"uid" IS DISTINCT FROM ?' + assert params == ["abc"] + + +def test_differs_filter_on_int(): + where, params, _ = build_where([("annee__differs", "2020")], SCHEMA) + assert where == '"annee" IS DISTINCT FROM ?' + assert params == [2020] +``` + +- [ ] **Step 2 : Lancer, vérifier l'échec** + +Run: `rtk pytest tests/api/test_filters.py::test_differs_filter tests/api/test_filters.py::test_differs_filter_on_int -v` +Expected: FAIL (`FilterError: Opérateur inconnu : __differs`). + +- [ ] **Step 3 : Ajouter `differs` à `OPERATORS`** + +Dans `src/api/filters.py`, ajouter `"differs",` dans le set `OPERATORS` (après `"notcontains",`). + +- [ ] **Step 4 : Ajouter la branche dans `build_where`** + +Dans `src/api/filters.py`, dans `build_where`, après le bloc `elif op == "notcontains":` (lignes ~132-134), ajouter : + +```python + elif op == "differs": + where_parts.append(f'"{col}" IS DISTINCT FROM ?') + params.append(v) +``` + +- [ ] **Step 5 : Lancer les tests unitaires, vérifier le succès** + +Run: `rtk pytest tests/api/test_filters.py -k differs -v` +Expected: PASS. + +- [ ] **Step 6 : Ajouter un test d'endpoint** + +Dans `tests/api/test_endpoints_data.py` : + +```python +def test_data_differs_excludes_value(api_client, valid_token_header): + client, _ = api_client + base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json() + uid = base["data"][0]["uid"] + resp = client.get(f"/api/v1/data?uid__differs={uid}", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert all(row["uid"] != uid for row in body["data"]) +``` + +- [ ] **Step 7 : Lancer, vérifier le succès** + +Run: `rtk pytest tests/api/test_endpoints_data.py::test_data_differs_excludes_value -v` +Expected: PASS. + +- [ ] **Step 8 : Commit** + +```bash +git add src/api/filters.py tests/api/test_filters.py tests/api/test_endpoints_data.py +git commit -m "feat(api): ajoute l'opérateur de filtre differs (IS DISTINCT FROM) (#78)" +``` + +--- + +### Task 3 : Parsing des agrégateurs (`parse_aggregators`) + +**Files:** + +- Modify: `src/api/filters.py` (constantes `AGGREGATORS`/`AGG_SQL`, dataclass `AggregationSpec`, fonction `parse_aggregators`, skip dans `build_where`) +- Test: `tests/api/test_filters.py` + +**Interfaces:** + +- Consumes: `_split_key`, `FilterError` (existants). +- Produces: + + - `AGGREGATORS: set[str]` = `{"groupby","count","sum","avg","min","max"}`. + - `@dataclass class AggregationSpec: select_sql: str; group_by_sql: str | None`. + - `parse_aggregators(args: list[tuple[str,str]], schema: pl.Schema) -> AggregationSpec | None` — `None` si aucun agrégateur. + - `build_where` ignore désormais les clés dont l'opérateur ∈ `AGGREGATORS`. + +- [ ] **Step 1 : Écrire les tests unitaires (échec attendu)** + +Dans `tests/api/test_filters.py`, ajouter l'import et les tests : + +```python +from src.api.filters import AggregationSpec, parse_aggregators + + +def test_parse_aggregators_none_when_absent(): + assert parse_aggregators([("uid__exact", "a")], SCHEMA) is None + + +def test_parse_aggregators_groupby_and_count(): + spec = parse_aggregators( + [("annee__groupby", ""), ("uid__count", "")], SCHEMA + ) + assert isinstance(spec, AggregationSpec) + assert spec.select_sql == '"annee", COUNT("uid") AS "uid__count"' + assert spec.group_by_sql == '"annee"' + + +def test_parse_aggregators_multiple_aggregates(): + spec = parse_aggregators( + [ + ("annee__groupby", ""), + ("montant__sum", ""), + ("montant__avg", ""), + ("montant__min", ""), + ("montant__max", ""), + ], + SCHEMA, + ) + assert spec.select_sql == ( + '"annee", SUM("montant") AS "montant__sum", ' + 'AVG("montant") AS "montant__avg", ' + 'MIN("montant") AS "montant__min", ' + 'MAX("montant") AS "montant__max"' + ) + assert spec.group_by_sql == '"annee"' + + +def test_parse_aggregators_global_without_groupby(): + spec = parse_aggregators([("uid__count", "")], SCHEMA) + assert spec.select_sql == 'COUNT("uid") AS "uid__count"' + assert spec.group_by_sql is None + + +def test_parse_aggregators_unknown_column_raises(): + with pytest.raises(FilterError): + parse_aggregators([("nope__count", "")], SCHEMA) + + +def test_build_where_ignores_aggregator_flags(): + where, params, _ = build_where( + [("annee__groupby", ""), ("uid__count", ""), ("montant__greater", "100")], + SCHEMA, + ) + assert where == '"montant" >= ?' + assert params == [100.0] +``` + +- [ ] **Step 2 : Lancer, vérifier l'échec** + +Run: `rtk pytest tests/api/test_filters.py -k aggregator -v` +Expected: FAIL (`ImportError: cannot import name 'parse_aggregators'`). + +- [ ] **Step 3 : Ajouter constantes + dataclass + fonction** + +Dans `src/api/filters.py`, ajouter en haut l'import `from dataclasses import dataclass` (après les imports existants), puis après `RESERVED_PARAMS` : + +```python +AGGREGATORS = {"groupby", "count", "sum", "avg", "min", "max"} +AGG_SQL = {"count": "COUNT", "sum": "SUM", "avg": "AVG", "min": "MIN", "max": "MAX"} + + +@dataclass +class AggregationSpec: + select_sql: str + group_by_sql: str | None + + +def parse_aggregators( + args: list[tuple[str, str]], schema: pl.Schema +) -> AggregationSpec | None: + """Détecte les drapeaux d'agrégation (`col__groupby`, `col__count`, ...). + + Retourne None si aucun agrégateur. Sinon, construit les fragments SQL + `select_sql` et `group_by_sql` (noms de colonnes validés contre le schéma). + """ + group_cols: list[str] = [] + aggregates: list[tuple[str, str]] = [] # (operator, column) + has_agg = False + + for key, _ in args: + parsed = _split_key(key) + if not parsed: + continue + col, op = parsed + if op not in AGGREGATORS: + continue + has_agg = True + if col not in schema: + raise FilterError(f"Colonne inconnue : {col!r}", field=key) + if op == "groupby": + group_cols.append(col) + else: + aggregates.append((op, col)) + + if not has_agg: + return None + + select_parts = [f'"{c}"' for c in group_cols] + for op, col in aggregates: + select_parts.append(f'{AGG_SQL[op]}("{col}") AS "{col}__{op}"') + + group_by_sql = ", ".join(f'"{c}"' for c in group_cols) if group_cols else None + return AggregationSpec(select_sql=", ".join(select_parts), group_by_sql=group_by_sql) +``` + +- [ ] **Step 4 : Faire ignorer les drapeaux d'agrégation par `build_where`** + +Dans `src/api/filters.py`, dans `build_where`, juste après `col, op = parsed` (et avant `if op not in OPERATORS:`), ajouter : + +```python + if op in AGGREGATORS: + continue +``` + +- [ ] **Step 5 : Lancer les tests, vérifier le succès** + +Run: `rtk pytest tests/api/test_filters.py -v` +Expected: PASS (tous, anciens et nouveaux). + +- [ ] **Step 6 : Commit** + +```bash +git add src/api/filters.py tests/api/test_filters.py +git commit -m "feat(api): parsing des opérateurs d'agrégation (groupby/count/sum/avg/min/max) (#78)" +``` + +--- + +### Task 4 : `aggregate_marches` dans la couche DB + +**Files:** + +- Modify: `src/db.py` (nouvelle fonction `aggregate_marches`) +- Test: `tests/api/test_db_aggregate.py` (créer) + +**Interfaces:** + +- Consumes: `get_cursor()`, `logger` (existants dans `src/db.py`). +- Produces: `aggregate_marches(select_sql: str, where_sql: str = "TRUE", params: tuple | list = (), group_by: str | None = None, limit: int | None = None, offset: int | None = None) -> pl.DataFrame`. + +- [ ] **Step 1 : Écrire le test (échec attendu)** + +Créer `tests/api/test_db_aggregate.py` : + +```python +import polars as pl + +from src.db import aggregate_marches + + +def test_aggregate_groupby_count_returns_named_columns(): + df = aggregate_marches( + select_sql='"acheteur_departement_code", COUNT("uid") AS "uid__count"', + group_by='"acheteur_departement_code"', + ) + assert isinstance(df, pl.DataFrame) + assert df.columns == ["acheteur_departement_code", "uid__count"] + assert df["uid__count"].sum() > 0 + + +def test_aggregate_global_without_groupby_returns_one_row(): + df = aggregate_marches(select_sql='COUNT("uid") AS "uid__count"') + assert df.height == 1 + assert df["uid__count"][0] > 0 +``` + +- [ ] **Step 2 : Lancer, vérifier l'échec** + +Run: `rtk pytest tests/api/test_db_aggregate.py -v` +Expected: FAIL (`ImportError: cannot import name 'aggregate_marches'`). + +- [ ] **Step 3 : Implémenter `aggregate_marches`** + +Dans `src/db.py`, après `count_marches` (vers la ligne 186), ajouter : + +```python +def aggregate_marches( + select_sql: str, + where_sql: str = "TRUE", + params: tuple | list = (), + group_by: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> pl.DataFrame: + """SELECT agrégé paramétré contre la table decp. + + `select_sql` et `group_by` sont des fragments SQL construits depuis des + noms de colonnes validés (jamais de valeur utilisateur libre). Les + valeurs de filtre passent par le binding `?` via `params`. + """ + sql = f"SELECT {select_sql} FROM decp WHERE {where_sql}" + if group_by: + sql += f" GROUP BY {group_by}" + if limit is not None: + sql += f" LIMIT {int(limit)}" + if offset is not None: + sql += f" OFFSET {int(offset)}" + + logger.debug("aggregate_marches: " + sql.replace("?", "{}").format(*params)) + + return get_cursor().execute(sql, list(params)).pl() +``` + +- [ ] **Step 4 : Lancer, vérifier le succès** + +Run: `rtk pytest tests/api/test_db_aggregate.py -v` +Expected: PASS. + +- [ ] **Step 5 : Commit** + +```bash +git add src/db.py tests/api/test_db_aggregate.py +git commit -m "feat(db): aggregate_marches pour les requêtes GROUP BY (#78)" +``` + +--- + +### Task 5 : Orchestration du mode agrégation dans la route + +**Files:** + +- Modify: `src/api/routes.py` (fonction `data()`) +- Test: `tests/api/test_endpoints_data.py` + +**Interfaces:** + +- Consumes: `parse_aggregators` (Task 3), `aggregate_marches` (Task 4), `build_where` (existant), `AggregationSpec`. +- Produces: l'endpoint `/api/v1/data` renvoie des lignes agrégées quand un opérateur d'agrégation est présent ; `meta` sans `total` ; `columns` + agrégation → 400. + +- [ ] **Step 1 : Écrire les tests d'endpoint (échec attendu)** + +Dans `tests/api/test_endpoints_data.py` : + +```python +def test_data_aggregation_groupby_count(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?acheteur_departement_code__groupby&uid__count", + headers=valid_token_header, + ) + assert resp.status_code == 200 + body = resp.get_json() + assert body["data"], "agrégation vide ?" + for row in body["data"]: + assert set(row.keys()) == {"acheteur_departement_code", "uid__count"} + assert "total" not in body["meta"] + + +def test_data_aggregation_global_count(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data?uid__count", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert len(body["data"]) == 1 + assert "uid__count" in body["data"][0] + + +def test_data_aggregation_with_filter(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?acheteur_departement_code__groupby&uid__count&montant__greater=0", + headers=valid_token_header, + ) + assert resp.status_code == 200 + + +def test_data_aggregation_with_columns_returns_400(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?uid__count&columns=uid", + headers=valid_token_header, + ) + assert resp.status_code == 400 +``` + +- [ ] **Step 2 : Lancer, vérifier l'échec** + +Run: `rtk pytest tests/api/test_endpoints_data.py -k aggregation -v` +Expected: FAIL (les drapeaux d'agrégation sont ignorés → réponse non agrégée, clés inattendues / pas de 400). + +- [ ] **Step 3 : Mettre à jour les imports de la route** + +Dans `src/api/routes.py`, remplacer la ligne d'import des filtres : + +```python +from src.api.filters import FilterError, build_where +``` + +par : + +```python +from src.api.filters import FilterError, build_where, parse_aggregators +from src.db import aggregate_marches +``` + +(et conserver l'import existant `from src.db import count_marches, query_marches`). + +- [ ] **Step 4 : Brancher le chemin agrégation dans `data()`** + +Dans `src/api/routes.py`, fonction `data()`, remplacer le bloc qui va de `try:` (parsing `build_where`) jusqu'au `return {...}` final par : + +```python + args = list(request.args.items(multi=True)) + try: + agg = parse_aggregators(args, duckdb_schema) + where_sql, params, order_sql = build_where(args, duckdb_schema) + except FilterError as e: + abort(400, message=str(e), errors={"field": e.field}) + + if agg is not None: + if columns: + abort( + 400, + message="`columns` ne peut pas être combiné avec une agrégation", + ) + df = aggregate_marches( + select_sql=agg.select_sql, + where_sql=where_sql, + params=params, + group_by=agg.group_by_sql, + limit=page_size, + offset=(page - 1) * page_size, + ) + df_ready = df.with_columns(cs.temporal().cast(pl.String)) + return { + "data": df_ready.to_dicts(), + "meta": {"page": page, "page_size": page_size}, + "links": _build_links(page, page_size, None), + } + + df = query_marches( + where_sql=where_sql, + params=params, + columns=columns, + order_by=order_sql, + limit=page_size, + offset=(page - 1) * page_size, + ) + + # JSON ne sérialise pas date/datetime nativement → cast en string ISO + df_ready = df.with_columns(cs.temporal().cast(pl.String)) + + total = count_marches(where_sql, params) if count_results else None + meta = {"page": page, "page_size": page_size} + if total is not None: + meta["total"] = total + + return { + "data": df_ready.to_dicts(), + "meta": meta, + "links": _build_links(page, page_size, total), + } +``` + +(Note : `count_results` provient de la Task 1 ; `columns`, `page`, `page_size` sont déjà calculés plus haut dans la fonction.) + +- [ ] **Step 5 : Lancer les tests d'endpoint, vérifier le succès** + +Run: `rtk pytest tests/api/test_endpoints_data.py -v` +Expected: PASS (tous). + +- [ ] **Step 6 : Commit** + +```bash +git add src/api/routes.py tests/api/test_endpoints_data.py +git commit -m "feat(api): mode agrégation sur /data (groupby + agrégats) (#78)" +``` + +--- + +### Task 6 : Documentation Swagger des mots-clés + +**Files:** + +- Modify: `src/api/routes.py` (bloc `@bp.doc` du param dynamique + docstring de `data()`) +- Test: `tests/api/test_openapi_doc.py` (créer) + +**Interfaces:** + +- Consumes: l'OpenAPI généré, servi sur `/api/v1/openapi.json`. +- Produces: la description du paramètre `__` liste tous les opérateurs (filtres + agrégation) avec une définition d'une ligne chacun, et décrit le mode agrégation. + +- [ ] **Step 1 : Écrire le test (échec attendu)** + +Créer `tests/api/test_openapi_doc.py` : + +```python +def test_openapi_documents_new_keywords(api_client): + client, _ = api_client + resp = client.get("/api/v1/openapi.json") + assert resp.status_code == 200 + raw = resp.get_data(as_text=True) + for keyword in ["count_results", "differs", "groupby", "__sum", "__avg", "__min", "__max"]: + assert keyword in raw, f"{keyword} absent de la doc OpenAPI" +``` + +- [ ] **Step 2 : Lancer, vérifier l'échec** + +Run: `rtk pytest tests/api/test_openapi_doc.py -v` +Expected: FAIL (`differs`, `groupby`, etc. absents de la description). + +- [ ] **Step 3 : Étoffer la description du paramètre dynamique** + +Dans `src/api/routes.py`, remplacer la `description` du paramètre `__` par : + +```python + "description": ( + "Filtre ou agrégation dynamique : `__` " + "(voir les colonnes via `/schema`).\n\n" + "**Filtres** (`__=`) :\n" + "- `exact` : égal à la valeur\n" + "- `differs` : différent de la valeur (null-safe, `IS DISTINCT FROM`)\n" + "- `contains` / `notcontains` : contient / ne contient pas (LIKE)\n" + "- `in` / `notin` : dans / hors d'une liste séparée par des virgules\n" + "- `less` / `greater` : ≤ / ≥\n" + "- `strictly_less` / `strictly_greater` : < / >\n" + "- `isnull` / `isnotnull` : valeur nulle / non nulle (sans valeur)\n" + "- `sort` : tri, valeur `asc` ou `desc`\n\n" + "**Agrégation** (drapeaux sans valeur, ex. `acheteur_departement_code__groupby&montant__sum`) :\n" + "- `groupby` : regroupe sur la colonne\n" + "- `count`, `sum`, `avg`, `min`, `max` : agrège la colonne ; " + "la colonne de sortie est nommée `colonne__count`, `colonne__sum`, " + "`colonne__avg`, `colonne__min`, `colonne__max`\n\n" + "En mode agrégation, la réponse contient des lignes groupées, " + "`columns` est interdit et `meta` ne contient pas `total`.\n\n" + "Exemples : `acheteur_id__contains=VILLE`, `montant__greater=10000`, " + "`acheteur_departement_code__groupby&montant__sum`." + ), +``` + +- [ ] **Step 4 : Mettre à jour le docstring de `data()`** + +Dans `src/api/routes.py`, remplacer le docstring de `data()` par : + +```python + """Récupère des marchés publics filtrés, triés ou agrégés. + + Filtres en query string : `__=`. + Opérateurs de filtre : exact, differs, contains, notcontains, in, notin, + less, greater, strictly_less, strictly_greater, isnull, isnotnull, sort. + + Agrégation (drapeaux sans valeur) : `__groupby`, + `__count|sum|avg|min|max`. Les colonnes agrégées sont nommées + `__`. `columns` est interdit avec une agrégation et + `meta` ne contient alors pas `total`. + + Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000), + columns (csv), count_results (true|false ; mettre false pour économiser + le COUNT(*)). + + Exemple d'agrégation : + `?acheteur_departement_code__groupby&uid__count&montant__sum` + """ +``` + +- [ ] **Step 5 : Lancer, vérifier le succès** + +Run: `rtk pytest tests/api/test_openapi_doc.py -v` +Expected: PASS. + +- [ ] **Step 6 : Vérifier la non-régression complète de l'API** + +Run: `rtk pytest tests/api/ -v` +Expected: PASS (tous). + +- [ ] **Step 7 : Commit** + +```bash +git add src/api/routes.py tests/api/test_openapi_doc.py +git commit -m "docs(api): documente les opérateurs (filtres + agrégation) dans Swagger (#78)" +``` + +--- + +## Notes de vérification de référence (manuel, hors tests automatisés) + +Après implémentation, vérifier que quelques requêtes d'agrégation renvoient +des valeurs cohérentes avec data.gouv.fr sur la même ressource DECP +(`22847056-61df-452d-837d-8b8ceadbfc52`), aux différences de fraîcheur près : + +``` +GET /api/v1/data?acheteur_departement_code__groupby&uid__count&montant__sum +``` + +à comparer à : + +``` +https://tabular-api.data.gouv.fr/api/resources/22847056-61df-452d-837d-8b8ceadbfc52/data/?acheteur_departement_code__groupby&uid__count&montant__sum +``` From f5db674e22cc2962d367efcdbc8da1a7c67dd93c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 13:50:12 +0200 Subject: [PATCH 099/151] =?UTF-8?q?feat(api):=20renomme=20le=20param=20r?= =?UTF-8?q?=C3=A9serv=C3=A9=20count=20en=20count=5Fresults=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renomme RESERVED_PARAMS dans src/api/filters.py - Fait passer le paramètre de 'count' à 'count_results' dans src/api/routes.py - Met à jour la documentation swagger pour le nouveau nom Co-Authored-By: Claude Sonnet 4.6 --- src/api/filters.py | 2 +- src/api/routes.py | 10 +++++----- tests/api/test_endpoints_data.py | 4 ++-- tests/api/test_filters.py | 2 +- tests/conftest.py | 3 ++- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/api/filters.py b/src/api/filters.py index 573dde8..9ca30d1 100644 --- a/src/api/filters.py +++ b/src/api/filters.py @@ -17,7 +17,7 @@ OPERATORS = { "sort", } -RESERVED_PARAMS = {"page", "page_size", "columns", "count"} +RESERVED_PARAMS = {"page", "page_size", "columns", "count_results"} class FilterError(ValueError): diff --git a/src/api/routes.py b/src/api/routes.py index 418492d..077d394 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -114,10 +114,10 @@ def schema(): "description": "Liste de colonnes à retourner, séparées par des virgules (ex: `id,acheteur_id,montant`). Par défaut : toutes.", }, { - "name": "count", + "name": "count_results", "in": "query", "schema": {"type": "string", "enum": ["true", "false"], "default": "true"}, - "description": "Inclure le total (`COUNT(*)`) dans la réponse. Mettre `false` pour accélérer la requête.", + "description": "Inclure le total (`COUNT(*)`) dans `meta`. Mettre `false` pour accélérer la requête. Ignoré en mode agrégation.", }, { "name": "__", @@ -142,14 +142,14 @@ def data(): strictly_less, strictly_greater, in, notin, isnull, isnotnull, sort. Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000), - columns (csv), count (true|false ; mettre false pour économiser le COUNT(*)). + columns (csv), count_results (true|false ; mettre false pour économiser le COUNT(*)). """ import polars as pl import polars.selectors as cs page, page_size = _parse_pagination() columns = _parse_columns() - count = request.args.get("count", "true").lower() != "false" + count_results = request.args.get("count_results", "true").lower() != "false" try: where_sql, params, order_sql = build_where( @@ -170,7 +170,7 @@ def data(): # JSON ne sérialise pas date/datetime nativement → cast en string ISO df_ready = df.with_columns(cs.temporal().cast(pl.String)) - total = count_marches(where_sql, params) if count else None + total = count_marches(where_sql, params) if count_results else None meta = {"page": page, "page_size": page_size} if total is not None: meta["total"] = total diff --git a/tests/api/test_endpoints_data.py b/tests/api/test_endpoints_data.py index 8911062..991c1c3 100644 --- a/tests/api/test_endpoints_data.py +++ b/tests/api/test_endpoints_data.py @@ -17,9 +17,9 @@ def test_data_default_pagination(api_client, valid_token_header): assert "total" in body["meta"] -def test_data_count_false_omits_total(api_client, valid_token_header): +def test_data_count_results_false_omits_total(api_client, valid_token_header): client, _ = api_client - resp = client.get("/api/v1/data?count=false", headers=valid_token_header) + resp = client.get("/api/v1/data?count_results=false", headers=valid_token_header) assert resp.status_code == 200 body = resp.get_json() assert "total" not in body["meta"] diff --git a/tests/api/test_filters.py b/tests/api/test_filters.py index b7706fe..8d3ecd5 100644 --- a/tests/api/test_filters.py +++ b/tests/api/test_filters.py @@ -114,7 +114,7 @@ def test_reserved_params_are_ignored(): ("page", "2"), ("page_size", "100"), ("columns", "uid"), - ("count", "false"), + ("count_results", "false"), ("uid__exact", "z"), ], SCHEMA, diff --git a/tests/conftest.py b/tests/conftest.py index 9ef9b6a..3ff37f7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -74,7 +74,8 @@ def test_data(): _cleanup_db_artifacts() -def pytest_setup_options(): +@pytest.fixture(scope="session") +def chrome_options(): options = Options() options.add_argument("--window-size=1200,1200 ") options.add_experimental_option( From 2a8e82ccd692a0d06ca1df261f93770b423be4ff Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 13:55:18 +0200 Subject: [PATCH 100/151] =?UTF-8?q?feat(api):=20ajoute=20l'op=C3=A9rateur?= =?UTF-8?q?=20de=20filtre=20differs=20(IS=20DISTINCT=20FROM)=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémente l'opérateur de filtre `differs` qui génère un fragment SQL `IS DISTINCT FROM` pour exclure des valeurs en tenant compte des NULL. Co-Authored-By: Claude Sonnet 4.6 --- src/api/filters.py | 4 ++++ tests/api/test_endpoints_data.py | 10 ++++++++++ tests/api/test_filters.py | 12 ++++++++++++ 3 files changed, 26 insertions(+) diff --git a/src/api/filters.py b/src/api/filters.py index 9ca30d1..2106fdc 100644 --- a/src/api/filters.py +++ b/src/api/filters.py @@ -6,6 +6,7 @@ OPERATORS = { "exact", "contains", "notcontains", + "differs", "less", "greater", "strictly_less", @@ -132,6 +133,9 @@ def build_where( elif op == "notcontains": where_parts.append(f'"{col}" NOT LIKE ?') params.append(f"%{v}%") + elif op == "differs": + where_parts.append(f'"{col}" IS DISTINCT FROM ?') + params.append(v) where_sql = " AND ".join(where_parts) if where_parts else "TRUE" order_sql = ", ".join(order_parts) if order_parts else None diff --git a/tests/api/test_endpoints_data.py b/tests/api/test_endpoints_data.py index 991c1c3..010f20a 100644 --- a/tests/api/test_endpoints_data.py +++ b/tests/api/test_endpoints_data.py @@ -102,3 +102,13 @@ def test_data_sort_desc(api_client, valid_token_header): row["dateNotification"] for row in body["data"] if row.get("dateNotification") ] assert dates == sorted(dates, reverse=True) + + +def test_data_differs_excludes_value(api_client, valid_token_header): + client, _ = api_client + base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json() + uid = base["data"][0]["uid"] + resp = client.get(f"/api/v1/data?uid__differs={uid}", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert all(row["uid"] != uid for row in body["data"]) diff --git a/tests/api/test_filters.py b/tests/api/test_filters.py index 8d3ecd5..239a317 100644 --- a/tests/api/test_filters.py +++ b/tests/api/test_filters.py @@ -139,3 +139,15 @@ def test_sort_invalid_direction_raises(): def test_param_without_operator_raises(): with pytest.raises(FilterError): build_where([("uidexact", "x")], SCHEMA) + + +def test_differs_filter(): + where, params, _ = build_where([("uid__differs", "abc")], SCHEMA) + assert where == '"uid" IS DISTINCT FROM ?' + assert params == ["abc"] + + +def test_differs_filter_on_int(): + where, params, _ = build_where([("annee__differs", "2020")], SCHEMA) + assert where == '"annee" IS DISTINCT FROM ?' + assert params == [2020] From 9324a734d41aceacfdef7bb45c5e3cc1379a2a91 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 13:59:50 +0200 Subject: [PATCH 101/151] =?UTF-8?q?feat(api):=20parsing=20des=20op=C3=A9ra?= =?UTF-8?q?teurs=20d'agr=C3=A9gation=20(groupby/count/sum/avg/min/max)=20(?= =?UTF-8?q?#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/filters.py | 53 +++++++++++++++++++++++++++++++++++++++ tests/api/test_filters.py | 53 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/api/filters.py b/src/api/filters.py index 2106fdc..f30a7c7 100644 --- a/src/api/filters.py +++ b/src/api/filters.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from datetime import date, datetime import polars as pl @@ -20,6 +21,55 @@ OPERATORS = { RESERVED_PARAMS = {"page", "page_size", "columns", "count_results"} +AGGREGATORS = {"groupby", "count", "sum", "avg", "min", "max"} +AGG_SQL = {"count": "COUNT", "sum": "SUM", "avg": "AVG", "min": "MIN", "max": "MAX"} + + +@dataclass +class AggregationSpec: + select_sql: str + group_by_sql: str | None + + +def parse_aggregators( + args: list[tuple[str, str]], schema: pl.Schema +) -> "AggregationSpec | None": + """Détecte les drapeaux d'agrégation (`col__groupby`, `col__count`, ...). + + Retourne None si aucun agrégateur. Sinon, construit les fragments SQL + `select_sql` et `group_by_sql` (noms de colonnes validés contre le schéma). + """ + group_cols: list[str] = [] + aggregates: list[tuple[str, str]] = [] # (operator, column) + has_agg = False + + for key, _ in args: + parsed = _split_key(key) + if not parsed: + continue + col, op = parsed + if op not in AGGREGATORS: + continue + has_agg = True + if col not in schema: + raise FilterError(f"Colonne inconnue : {col!r}", field=key) + if op == "groupby": + group_cols.append(col) + else: + aggregates.append((op, col)) + + if not has_agg: + return None + + select_parts = [f'"{c}"' for c in group_cols] + for op, col in aggregates: + select_parts.append(f'{AGG_SQL[op]}("{col}") AS "{col}__{op}"') + + group_by_sql = ", ".join(f'"{c}"' for c in group_cols) if group_cols else None + return AggregationSpec( + select_sql=", ".join(select_parts), group_by_sql=group_by_sql + ) + class FilterError(ValueError): def __init__(self, message: str, field: str | None = None): @@ -85,6 +135,9 @@ def build_where( raise FilterError(f"Paramètre non reconnu : {key}", field=key) col, op = parsed + if op in AGGREGATORS: + continue + if op not in OPERATORS: raise FilterError(f"Opérateur inconnu : __{op}", field=key) diff --git a/tests/api/test_filters.py b/tests/api/test_filters.py index 239a317..b3ecd39 100644 --- a/tests/api/test_filters.py +++ b/tests/api/test_filters.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from src.api.filters import FilterError, build_where +from src.api.filters import AggregationSpec, FilterError, build_where, parse_aggregators SCHEMA = pl.Schema( { @@ -151,3 +151,54 @@ def test_differs_filter_on_int(): where, params, _ = build_where([("annee__differs", "2020")], SCHEMA) assert where == '"annee" IS DISTINCT FROM ?' assert params == [2020] + + +def test_parse_aggregators_none_when_absent(): + assert parse_aggregators([("uid__exact", "a")], SCHEMA) is None + + +def test_parse_aggregators_groupby_and_count(): + spec = parse_aggregators([("annee__groupby", ""), ("uid__count", "")], SCHEMA) + assert isinstance(spec, AggregationSpec) + assert spec.select_sql == '"annee", COUNT("uid") AS "uid__count"' + assert spec.group_by_sql == '"annee"' + + +def test_parse_aggregators_multiple_aggregates(): + spec = parse_aggregators( + [ + ("annee__groupby", ""), + ("montant__sum", ""), + ("montant__avg", ""), + ("montant__min", ""), + ("montant__max", ""), + ], + SCHEMA, + ) + assert spec.select_sql == ( + '"annee", SUM("montant") AS "montant__sum", ' + 'AVG("montant") AS "montant__avg", ' + 'MIN("montant") AS "montant__min", ' + 'MAX("montant") AS "montant__max"' + ) + assert spec.group_by_sql == '"annee"' + + +def test_parse_aggregators_global_without_groupby(): + spec = parse_aggregators([("uid__count", "")], SCHEMA) + assert spec.select_sql == 'COUNT("uid") AS "uid__count"' + assert spec.group_by_sql is None + + +def test_parse_aggregators_unknown_column_raises(): + with pytest.raises(FilterError): + parse_aggregators([("nope__count", "")], SCHEMA) + + +def test_build_where_ignores_aggregator_flags(): + where, params, _ = build_where( + [("annee__groupby", ""), ("uid__count", ""), ("montant__greater", "100")], + SCHEMA, + ) + assert where == '"montant" >= ?' + assert params == [100.0] From 28709a162a7a988acda430831321e3db1b6fdaee Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 14:04:08 +0200 Subject: [PATCH 102/151] =?UTF-8?q?feat(db):=20aggregate=5Fmarches=20pour?= =?UTF-8?q?=20les=20requ=C3=AAtes=20GROUP=20BY=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/db.py | 27 +++++++++++++++++++++++++++ tests/api/test_db_aggregate.py | 19 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/api/test_db_aggregate.py diff --git a/src/db.py b/src/db.py index c4d0892..0235f6f 100644 --- a/src/db.py +++ b/src/db.py @@ -191,3 +191,30 @@ def count_unique_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> logger.debug("count_unique_marches: " + sql.replace("?", "{}").format(*params)) result = get_cursor().execute(sql, list(params)).fetchone() return int(result[0]) if result else 0 + + +def aggregate_marches( + select_sql: str, + where_sql: str = "TRUE", + params: tuple | list = (), + group_by: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> pl.DataFrame: + """SELECT agrégé paramétré contre la table decp. + + `select_sql` et `group_by` sont des fragments SQL construits depuis des + noms de colonnes validés (jamais de valeur utilisateur libre). Les + valeurs de filtre passent par le binding `?` via `params`. + """ + sql = f"SELECT {select_sql} FROM decp WHERE {where_sql}" + if group_by: + sql += f" GROUP BY {group_by}" + if limit is not None: + sql += f" LIMIT {int(limit)}" + if offset is not None: + sql += f" OFFSET {int(offset)}" + + logger.debug("aggregate_marches: " + sql.replace("?", "{}").format(*params)) + + return get_cursor().execute(sql, list(params)).pl() diff --git a/tests/api/test_db_aggregate.py b/tests/api/test_db_aggregate.py new file mode 100644 index 0000000..191083b --- /dev/null +++ b/tests/api/test_db_aggregate.py @@ -0,0 +1,19 @@ +import polars as pl + +from src.db import aggregate_marches + + +def test_aggregate_groupby_count_returns_named_columns(): + df = aggregate_marches( + select_sql='"acheteur_departement_code", COUNT("uid") AS "uid__count"', + group_by='"acheteur_departement_code"', + ) + assert isinstance(df, pl.DataFrame) + assert df.columns == ["acheteur_departement_code", "uid__count"] + assert df["uid__count"].sum() > 0 + + +def test_aggregate_global_without_groupby_returns_one_row(): + df = aggregate_marches(select_sql='COUNT("uid") AS "uid__count"') + assert df.height == 1 + assert df["uid__count"][0] > 0 From 7c9a95355b804749c3a6fc48c727cb9f27fcfdb5 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 14:06:44 +0200 Subject: [PATCH 103/151] =?UTF-8?q?feat(api):=20mode=20agr=C3=A9gation=20s?= =?UTF-8?q?ur=20/data=20(groupby=20+=20agr=C3=A9gats)=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/routes.py | 31 ++++++++++++++++++++---- tests/api/test_endpoints_data.py | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/api/routes.py b/src/api/routes.py index 077d394..dbc5fdc 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -3,8 +3,8 @@ from flask_smorest import Blueprint, abort from src.api import tracking from src.api.auth import require_token -from src.api.filters import FilterError, build_where -from src.db import count_marches, query_marches +from src.api.filters import FilterError, build_where, parse_aggregators +from src.db import aggregate_marches, count_marches, query_marches from src.db import schema as duckdb_schema from src.utils.data import DATA_SCHEMA @@ -151,13 +151,34 @@ def data(): columns = _parse_columns() count_results = request.args.get("count_results", "true").lower() != "false" + args = list(request.args.items(multi=True)) try: - where_sql, params, order_sql = build_where( - list(request.args.items(multi=True)), duckdb_schema - ) + agg = parse_aggregators(args, duckdb_schema) + where_sql, params, order_sql = build_where(args, duckdb_schema) except FilterError as e: abort(400, message=str(e), errors={"field": e.field}) + if agg is not None: + if columns: + abort( + 400, + message="`columns` ne peut pas être combiné avec une agrégation", + ) + df = aggregate_marches( + select_sql=agg.select_sql, + where_sql=where_sql, + params=params, + group_by=agg.group_by_sql, + limit=page_size, + offset=(page - 1) * page_size, + ) + df_ready = df.with_columns(cs.temporal().cast(pl.String)) + return { + "data": df_ready.to_dicts(), + "meta": {"page": page, "page_size": page_size}, + "links": _build_links(page, page_size, None), + } + df = query_marches( where_sql=where_sql, params=params, diff --git a/tests/api/test_endpoints_data.py b/tests/api/test_endpoints_data.py index 010f20a..d024de5 100644 --- a/tests/api/test_endpoints_data.py +++ b/tests/api/test_endpoints_data.py @@ -112,3 +112,44 @@ def test_data_differs_excludes_value(api_client, valid_token_header): assert resp.status_code == 200 body = resp.get_json() assert all(row["uid"] != uid for row in body["data"]) + + +def test_data_aggregation_groupby_count(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?acheteur_departement_code__groupby&uid__count", + headers=valid_token_header, + ) + assert resp.status_code == 200 + body = resp.get_json() + assert body["data"], "agrégation vide ?" + for row in body["data"]: + assert set(row.keys()) == {"acheteur_departement_code", "uid__count"} + assert "total" not in body["meta"] + + +def test_data_aggregation_global_count(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data?uid__count", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert len(body["data"]) == 1 + assert "uid__count" in body["data"][0] + + +def test_data_aggregation_with_filter(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?acheteur_departement_code__groupby&uid__count&montant__greater=0", + headers=valid_token_header, + ) + assert resp.status_code == 200 + + +def test_data_aggregation_with_columns_returns_400(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?uid__count&columns=uid", + headers=valid_token_header, + ) + assert resp.status_code == 400 From b87eb4328cf7b8b43d6be7794499488c8f025b39 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 14:09:34 +0200 Subject: [PATCH 104/151] =?UTF-8?q?docs(api):=20documente=20les=20op=C3=A9?= =?UTF-8?q?rateurs=20(filtres=20+=20agr=C3=A9gation)=20dans=20Swagger=20(#?= =?UTF-8?q?78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace la description du paramètre __ par une documentation complète listant tous les opérateurs de filtre (exact, differs, contains, notcontains, in, notin, less, greater, strictly_less, strictly_greater, isnull, isnotnull, sort) et les agrégations (groupby, count, sum, avg, min, max). Met à jour le docstring de data() pour documenter le mode agrégation et les paramètres réservés. Ajoute test_openapi_doc.py qui valide la présence des mots-clés clés dans l'OpenAPI généré. Co-Authored-By: Claude Sonnet 4.6 --- src/api/routes.py | 42 +++++++++++++++++++++++++++-------- tests/api/test_openapi_doc.py | 15 +++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 tests/api/test_openapi_doc.py diff --git a/src/api/routes.py b/src/api/routes.py index dbc5fdc..e4080ce 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -124,25 +124,49 @@ def schema(): "in": "query", "schema": {"type": "string"}, "description": ( - "Filtre dynamique. Remplacer `` par un nom de colonne (voir `/schema`) " - "et `` par : `exact`, `contains`, `notcontains`, `less`, `greater`, " - "`strictly_less`, `strictly_greater`, `in`, `notin`, `isnull`, `isnotnull`, `sort`. " - "Exemple : `acheteur_id__contains=VILLE`, `montant__greater=10000`." + "Filtre ou agrégation dynamique : `__` " + "(voir les colonnes via `/schema`).\n\n" + "**Filtres** (`__=`) :\n" + "- `exact` : égal à la valeur\n" + "- `differs` : différent de la valeur (null-safe, `IS DISTINCT FROM`)\n" + "- `contains` / `notcontains` : contient / ne contient pas (LIKE)\n" + "- `in` / `notin` : dans / hors d'une liste séparée par des virgules\n" + "- `less` / `greater` : ≤ / ≥\n" + "- `strictly_less` / `strictly_greater` : < / >\n" + "- `isnull` / `isnotnull` : valeur nulle / non nulle (sans valeur)\n" + "- `sort` : tri, valeur `asc` ou `desc`\n\n" + "**Agrégation** (drapeaux sans valeur, ex. `acheteur_departement_code__groupby&montant__sum`) :\n" + "- `groupby` : regroupe sur la colonne\n" + "- `count`, `sum`, `avg`, `min`, `max` : agrège la colonne ; " + "la colonne de sortie est nommée `colonne__count`, `colonne__sum`, " + "`colonne__avg`, `colonne__min`, `colonne__max`\n\n" + "En mode agrégation, la réponse contient des lignes groupées, " + "`columns` est interdit et `meta` ne contient pas `total`.\n\n" + "Exemples : `acheteur_id__contains=VILLE`, `montant__greater=10000`, " + "`acheteur_departement_code__groupby&montant__sum`." ), }, ], ) @require_token def data(): - """Récupère des marchés publics filtrés. + """Récupère des marchés publics filtrés, triés ou agrégés. - Filtres en query string sous la forme `__=`. + Filtres en query string : `__=`. + Opérateurs de filtre : exact, differs, contains, notcontains, in, notin, + less, greater, strictly_less, strictly_greater, isnull, isnotnull, sort. - Opérateurs : exact, contains, notcontains, less, greater, - strictly_less, strictly_greater, in, notin, isnull, isnotnull, sort. + Agrégation (drapeaux sans valeur) : `__groupby`, + `__count|sum|avg|min|max`. Les colonnes agrégées sont nommées + `__`. `columns` est interdit avec une agrégation et + `meta` ne contient alors pas `total`. Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000), - columns (csv), count_results (true|false ; mettre false pour économiser le COUNT(*)). + columns (csv), count_results (true|false ; mettre false pour économiser + le COUNT(*)). + + Exemple d'agrégation : + `?acheteur_departement_code__groupby&uid__count&montant__sum` """ import polars as pl import polars.selectors as cs diff --git a/tests/api/test_openapi_doc.py b/tests/api/test_openapi_doc.py new file mode 100644 index 0000000..87b683d --- /dev/null +++ b/tests/api/test_openapi_doc.py @@ -0,0 +1,15 @@ +def test_openapi_documents_new_keywords(api_client): + client, _ = api_client + resp = client.get("/api/v1/openapi.json") + assert resp.status_code == 200 + raw = resp.get_data(as_text=True) + for keyword in [ + "count_results", + "differs", + "groupby", + "__sum", + "__avg", + "__min", + "__max", + ]: + assert keyword in raw, f"{keyword} absent de la doc OpenAPI" From 70ee819a514c0b0548524bb581c0163bff39c7e7 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 14:20:02 +0200 Subject: [PATCH 105/151] =?UTF-8?q?feat(api):=20sort=20support=C3=A9=20sur?= =?UTF-8?q?=20colonnes=20groupby=20en=20mode=20agr=C3=A9gation=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testé contre tabular-api data.gouv.fr : sort sur une colonne groupby est honoré (200), sort sur un alias d'agrégat retourne 400 (colonne inconnue). aggregate_marches reçoit order_by ; doc Swagger mise à jour. Co-Authored-By: Claude Sonnet 4.6 --- src/api/routes.py | 11 ++++++++--- src/db.py | 9 ++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/api/routes.py b/src/api/routes.py index e4080ce..2fa1915 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -141,9 +141,12 @@ def schema(): "la colonne de sortie est nommée `colonne__count`, `colonne__sum`, " "`colonne__avg`, `colonne__min`, `colonne__max`\n\n" "En mode agrégation, la réponse contient des lignes groupées, " - "`columns` est interdit et `meta` ne contient pas `total`.\n\n" + "`columns` est interdit et `meta` ne contient pas `total`. " + "`sort` peut être appliqué sur une colonne `groupby` (ex. `acheteur_departement_code__sort=asc`) ; " + "il n'est pas supporté sur les alias d'agrégats (ex. `uid__count__sort=desc` → 400).\n\n" "Exemples : `acheteur_id__contains=VILLE`, `montant__greater=10000`, " - "`acheteur_departement_code__groupby&montant__sum`." + "`acheteur_departement_code__groupby&montant__sum`, " + "`acheteur_departement_code__groupby&uid__count&acheteur_departement_code__sort=asc`." ), }, ], @@ -159,7 +162,8 @@ def data(): Agrégation (drapeaux sans valeur) : `__groupby`, `__count|sum|avg|min|max`. Les colonnes agrégées sont nommées `__`. `columns` est interdit avec une agrégation et - `meta` ne contient alors pas `total`. + `meta` ne contient alors pas `total`. `sort` est supporté sur les colonnes + `groupby` ; non supporté sur les alias d'agrégats (→ 400). Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000), columns (csv), count_results (true|false ; mettre false pour économiser @@ -193,6 +197,7 @@ def data(): where_sql=where_sql, params=params, group_by=agg.group_by_sql, + order_by=order_sql or None, limit=page_size, offset=(page - 1) * page_size, ) diff --git a/src/db.py b/src/db.py index 0235f6f..cc148ed 100644 --- a/src/db.py +++ b/src/db.py @@ -198,18 +198,21 @@ def aggregate_marches( where_sql: str = "TRUE", params: tuple | list = (), group_by: str | None = None, + order_by: str | None = None, limit: int | None = None, offset: int | None = None, ) -> pl.DataFrame: """SELECT agrégé paramétré contre la table decp. - `select_sql` et `group_by` sont des fragments SQL construits depuis des - noms de colonnes validés (jamais de valeur utilisateur libre). Les - valeurs de filtre passent par le binding `?` via `params`. + `select_sql`, `group_by` et `order_by` sont des fragments SQL construits + depuis des noms de colonnes validés (jamais de valeur utilisateur libre). + Les valeurs de filtre passent par le binding `?` via `params`. """ sql = f"SELECT {select_sql} FROM decp WHERE {where_sql}" if group_by: sql += f" GROUP BY {group_by}" + if order_by: + sql += f" ORDER BY {order_by}" if limit is not None: sql += f" LIMIT {int(limit)}" if offset is not None: From c264c3668474d11aec994a54e1c4041ef65362b5 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 14:23:09 +0200 Subject: [PATCH 106/151] =?UTF-8?q?fix(api):=20next=3Dnull=20sur=20derni?= =?UTF-8?q?=C3=A8re=20page=20en=20mode=20agr=C3=A9gation=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Si la page retournée est partielle (len < page_size), on sait qu'on est sur la dernière page → next=null, parité data.gouv.fr. Co-Authored-By: Claude Sonnet 4.6 --- src/api/routes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/routes.py b/src/api/routes.py index 2fa1915..6baf0a5 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -202,10 +202,14 @@ def data(): offset=(page - 1) * page_size, ) df_ready = df.with_columns(cs.temporal().cast(pl.String)) + # Si la page est partielle, on connaît le total exact ; sinon on ne sait pas. + agg_total = ( + (page - 1) * page_size + df.height if df.height < page_size else None + ) return { "data": df_ready.to_dicts(), "meta": {"page": page, "page_size": page_size}, - "links": _build_links(page, page_size, None), + "links": _build_links(page, page_size, agg_total), } df = query_marches( From 966d193ec4bd4d181802c38e0734a97e93bacfd8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 15:16:35 +0200 Subject: [PATCH 107/151] test(api): benchmark comparatif decp.info vs data.gouv.fr (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requête identique envoyée aux deux APIs (drapeaux d'agrégation nus), tableau côte à côte + ratio decp/datagouv. Couvre filtres, differs, agrégation. --url decp.info paramétrable (défaut prod), data.gouv.fr sans auth. Co-Authored-By: Claude Opus 4.8 --- tests/api/benchmark.py | 290 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 tests/api/benchmark.py diff --git a/tests/api/benchmark.py b/tests/api/benchmark.py new file mode 100644 index 0000000..8eab3a0 --- /dev/null +++ b/tests/api/benchmark.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python +"""Benchmark comparatif de l'endpoint /data : decp.info vs data.gouv.fr. + +Les deux APIs partagent le même schéma de requête (mêmes opérateurs), donc +chaque scénario est envoyé à l'identique aux deux et les temps de réponse +sont comparés côte à côte. + +Usage : + python tests/api/benchmark.py --token decpinfo_xxx + python tests/api/benchmark.py --url http://localhost:8050/api/v1/data --token decpinfo_xxx + python tests/api/benchmark.py --decp-only --token decpinfo_xxx --runs 20 + +Par défaut, --url pointe vers la production decp.info ; data.gouv.fr est +interrogé sans authentification. + +AVERTISSEMENT : les deux bases n'ont pas le même volume (data.gouv.fr ~3M +lignes, decp.info ~1,5M). C'est une comparaison d'implémentation, pas à +volume égal. +""" + +import argparse +import sys +import time +from dataclasses import dataclass, field +from urllib.parse import quote + +import httpx + +DECP_DEFAULT_URL = "https://decp.info/api/v1/data" +DATAGOUV_DEFAULT_URL = ( + "https://tabular-api.data.gouv.fr/api/resources/" + "22847056-61df-452d-837d-8b8ceadbfc52/data/" +) + +# Chaque scénario : liste de (clé, valeur). valeur=None → drapeau nu (sans `=`), +# requis par data.gouv.fr pour les opérateurs d'agrégation et isnull. +SCENARIOS: list[dict] = [ + { + "name": "sans filtre (page 1, 50 résultats)", + "params": [("page", "1"), ("page_size", "50")], + }, + { + "name": "filtre __exact sur département", + "params": [("acheteur_departement_code__exact", "44"), ("page_size", "50")], + }, + { + "name": "filtre __differs sur département", + "params": [("acheteur_departement_code__differs", "44"), ("page_size", "50")], + }, + { + "name": "filtre __contains sur objet", + "params": [("objet__contains", "informatique"), ("page_size", "50")], + }, + { + "name": "filtre __greater sur date", + "params": [("dateNotification__greater", "2024-01-01"), ("page_size", "50")], + }, + { + "name": "filtre __strictly_greater sur montant", + "params": [("montant__strictly_greater", "100000"), ("page_size", "50")], + }, + { + "name": "filtre __in (CPV multiples)", + "params": [("codeCPV__in", "72000000,72200000"), ("page_size", "50")], + }, + { + "name": "filtre __isnull sur montant", + "params": [("montant__isnull", None), ("page_size", "50")], + }, + { + "name": "tri desc + colonnes sélectionnées", + "params": [ + ("dateNotification__sort", "desc"), + ("columns", "uid,objet,montant,dateNotification"), + ("page_size", "50"), + ], + }, + { + "name": "filtres combinés", + "params": [ + ("acheteur_departement_code__exact", "75"), + ("dateNotification__greater", "2023-01-01"), + ("montant__strictly_greater", "50000"), + ("dateNotification__sort", "desc"), + ("page_size", "50"), + ], + }, + { + "name": "agrégation groupby + count", + "params": [ + ("acheteur_departement_code__groupby", None), + ("uid__count", None), + ("page_size", "100"), + ], + }, + { + "name": "agrégation groupby + sum + avg", + "params": [ + ("acheteur_departement_code__groupby", None), + ("montant__sum", None), + ("montant__avg", None), + ("page_size", "100"), + ], + }, + { + "name": "page 2", + "params": [("page", "2"), ("page_size", "50")], + }, + { + "name": "count_results=false (optim COUNT(*))", + "params": [("page_size", "50"), ("count_results", "false")], + "decp_only": True, + }, +] + +COL_NAME = 40 +COL_STAT = 9 + + +@dataclass +class Target: + label: str + base_url: str + headers: dict = field(default_factory=dict) + + +def build_query(params: list[tuple[str, str | None]]) -> str: + """Construit la query string. valeur=None → clé nue (sans `=`).""" + parts = [] + for key, value in params: + if value is None: + parts.append(key) + else: + parts.append(f"{key}={quote(str(value), safe=',:')}") + return "&".join(parts) + + +def percentile(data: list[float], p: float) -> float: + if not data: + return float("nan") + sorted_data = sorted(data) + k = (len(sorted_data) - 1) * p / 100 + lo, hi = int(k), min(int(k) + 1, len(sorted_data) - 1) + return sorted_data[lo] + (sorted_data[hi] - sorted_data[lo]) * (k - lo) + + +def measure(target: Target, query: str, runs: int) -> dict | None: + """Chauffe (1 requête non mesurée) puis chronomètre `runs` requêtes.""" + url = f"{target.base_url}?{query}" + try: + warm = httpx.get(url, headers=target.headers, timeout=30) + last_status = warm.status_code + except httpx.RequestError as exc: + return {"error": str(exc), "status": 0} + + timings: list[float] = [] + for _ in range(runs): + try: + t0 = time.perf_counter() + resp = httpx.get(url, headers=target.headers, timeout=30) + timings.append((time.perf_counter() - t0) * 1000) + last_status = resp.status_code + except httpx.RequestError as exc: + return {"error": str(exc), "status": 0} + + return { + "status": last_status, + "median": percentile(timings, 50), + "p95": percentile(timings, 95), + "min": min(timings), + "max": max(timings), + } + + +def run_benchmark(targets: list[Target], decp_label: str, runs: int) -> None: + print("\nAVERTISSEMENT : volumes de données différents entre les deux APIs.") + print(f"Scénarios : {len(SCENARIOS)} | Répétitions : {runs}\n") + + rows: list[dict] = [] + for scenario in SCENARIOS: + query = build_query(scenario["params"]) + decp_only = scenario.get("decp_only", False) + active = [t for t in targets if not (decp_only and t.label != decp_label)] + + measures = {t.label: measure(t, query, runs) for t in active} + rows.append({"name": scenario["name"], "measures": measures}) + + bits = [] + for t in active: + m = measures[t.label] + if "error" in m: + bits.append(f"{t.label}: ERREUR") + else: + bits.append(f"{t.label}: méd {m['median']:.0f}ms [{m['status']}]") + print(f" {scenario['name'][:COL_NAME]:<{COL_NAME}} " + " | ".join(bits)) + + _print_summary(rows, targets, decp_label) + + +def _fmt(m: dict | None, key: str) -> str: + if m is None: + return "—" + if "error" in m: + return "ERR" + return f"{m[key]:.0f}" + + +def _print_summary(rows: list[dict], targets: list[Target], decp_label: str) -> None: + dg = next((t.label for t in targets if t.label != decp_label), None) + + header = ( + f"{'Scénario':<{COL_NAME}}" + f" {'DG méd':>{COL_STAT}} {'DG p95':>{COL_STAT}}" + f" {'decp méd':>{COL_STAT}} {'decp p95':>{COL_STAT}}" + f" {'ratio':>7}" + ) + sep = "-" * len(header) + print( + f"\n{'=' * len(header)}\nRÉSUMÉ (ratio = decp / data.gouv.fr, <1 = decp plus rapide)" + ) + print(f"{'=' * len(header)}\n{header}\n{sep}") + + for row in rows: + m_decp = row["measures"].get(decp_label) + m_dg = row["measures"].get(dg) if dg else None + + ratio = "—" + if m_decp and m_dg and "error" not in m_decp and "error" not in m_dg: + if m_dg["median"] > 0: + ratio = f"{m_decp['median'] / m_dg['median']:.2f}" + + print( + f"{row['name'][:COL_NAME]:<{COL_NAME}}" + f" {_fmt(m_dg, 'median'):>{COL_STAT}} {_fmt(m_dg, 'p95'):>{COL_STAT}}" + f" {_fmt(m_decp, 'median'):>{COL_STAT}} {_fmt(m_decp, 'p95'):>{COL_STAT}}" + f" {ratio:>7}" + ) + print(sep) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Benchmark comparatif decp.info vs data.gouv.fr (/data)" + ) + parser.add_argument( + "--url", + default=DECP_DEFAULT_URL, + help=f"Endpoint /data de decp.info (défaut : {DECP_DEFAULT_URL})", + ) + parser.add_argument( + "--datagouv-url", + default=DATAGOUV_DEFAULT_URL, + help="Endpoint /data/ de la ressource data.gouv.fr", + ) + parser.add_argument( + "--token", + default=None, + help="Token Bearer decp.info (format decpinfo_xxx)", + ) + parser.add_argument( + "--runs", + type=int, + default=5, + help="Répétitions chronométrées par scénario (défaut : 5)", + ) + parser.add_argument( + "--decp-only", + action="store_true", + help="Ne benchmarker que decp.info (saute data.gouv.fr)", + ) + args = parser.parse_args() + + if args.runs < 1: + print("--runs doit être ≥ 1", file=sys.stderr) + sys.exit(1) + + decp_label = "decp.info" + decp_headers = {"Authorization": f"Bearer {args.token}"} if args.token else {} + decp = Target(label=decp_label, base_url=args.url, headers=decp_headers) + + targets = [decp] + if not args.decp_only: + # data.gouv.fr d'abord pour l'affichage côte à côte + targets.insert(0, Target(label="data.gouv.fr", base_url=args.datagouv_url)) + + run_benchmark(targets, decp_label, args.runs) + + +if __name__ == "__main__": + main() From 992f6d4f93f3643853cce7a229aa1bf17f923bdd Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 17:46:31 +0200 Subject: [PATCH 108/151] Recherche dans les tableaux insensible aux accents #79 --- src/utils/table.py | 30 +++++++++++++++++++++++++----- tests/test_table.py | 27 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/utils/table.py b/src/utils/table.py index ef5221c..fa17880 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -4,6 +4,7 @@ import uuid import polars as pl from dash import no_update from polars import selectors as cs +from unidecode import unidecode from src.db import count_marches, count_unique_marches, query_marches, schema from src.utils import logger @@ -215,6 +216,25 @@ def format_values(dff: pl.DataFrame) -> pl.DataFrame: return dff +_ACCENT_REPLACEMENTS = [ + ("[éèêëÉÈÊË]", "e"), + ("[àâäÀÂÄ]", "a"), + ("[ùûüÙÛÜ]", "u"), + ("[îïÎÏ]", "i"), + ("[ôöÔÖ]", "o"), + ("[çÇ]", "c"), + ("[ñÑ]", "n"), + ("[æÆ]", "ae"), + ("[œŒ]", "oe"), +] + + +def _deaccent_col(expr: pl.Expr) -> pl.Expr: + for pattern, replacement in _ACCENT_REPLACEMENTS: + expr = expr.str.replace_all(pattern, replacement) + return expr + + def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame: _schema = lff.collect_schema() filtering_expressions = filter_query.split(" && ") @@ -256,17 +276,17 @@ def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame: elif operator == "contains": if col_type in ["String", "Date"] and isinstance(filter_value, str): filter_value = filter_value.strip('"') + normalized_value = unidecode(filter_value) + col_expr = _deaccent_col(pl.col(col_name)) if filter_value.endswith("*"): lff = lff.filter( - pl.col(col_name).str.starts_with(filter_value[:-1]) + col_expr.str.starts_with(normalized_value[:-1]) ) elif filter_value.startswith("*"): - lff = lff.filter( - pl.col(col_name).str.ends_with(filter_value[1:]) - ) + lff = lff.filter(col_expr.str.ends_with(normalized_value[1:])) else: lff = lff.filter( - pl.col(col_name).str.contains("(?i)" + filter_value) + col_expr.str.contains("(?i)" + normalized_value) ) elif col_type.startswith("Int") or col_type.startswith("Float"): lff = lff.filter(pl.col(col_name) == filter_value) diff --git a/tests/test_table.py b/tests/test_table.py index 643e34c..0df5e50 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -44,6 +44,33 @@ def test_filter_table_data_does_not_call_track_search(monkeypatch, sample_lff): assert result.height == 1 +def test_filter_table_data_accent_insensitive(): + """Chercher sans accent doit trouver des valeurs accentuées, et vice versa.""" + from src.utils.table import filter_table_data + + lff = pl.LazyFrame( + [ + {"uid": "1", "acheteur_nom": "Mairie de Nîmes", "objet": "Voirie"}, + {"uid": "2", "acheteur_nom": "Commune de Reims", "objet": "Éclairage"}, + {"uid": "3", "acheteur_nom": "Ville de Paris", "objet": "Travaux"}, + ] + ) + + # Sans accent → trouve la valeur accentuée + result = filter_table_data(lff, "{acheteur_nom} icontains Nimes").collect() + assert result.height == 1 + assert result["uid"][0] == "1" + + # Avec accent → trouve la valeur accentuée + result = filter_table_data(lff, "{acheteur_nom} icontains Nîmes").collect() + assert result.height == 1 + + # Sans accent → trouve la valeur avec accent initial (É) + result = filter_table_data(lff, "{objet} icontains eclairage").collect() + assert result.height == 1 + assert result["uid"][0] == "2" + + def test_normalize_sort_by_handles_empty(): from src.utils.table import normalize_sort_by From e44af8add8507c3ada7b8ad3479bee2970f19e60 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 17:55:53 +0200 Subject: [PATCH 109/151] =?UTF-8?q?Dans=20les=20r=C3=A9sultats=20de=20rech?= =?UTF-8?q?erche,=20le=20contenu=20de=20la=20colonne=20D=C3=A9partement=20?= =?UTF-8?q?wrappe=20le=20texte=20#77?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 3288369..ebe7ecb 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -177,6 +177,10 @@ p.version > a { margin-right: 12px; } +#search_results td[data-dash-column="Département"] { + text-wrap: wrap; +} + /* --- Dashboard inputs --- */ .Select--multi .Select-value { From 826448d6be9926a13eebde1463797aa07dcd59fe Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 18:09:05 +0200 Subject: [PATCH 110/151] =?UTF-8?q?docs:=20spec=20tuile=20consid=C3=A9rati?= =?UTF-8?q?ons=20sociales/environnementales=20(observatoire)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...observatoire-considerations-card-design.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-observatoire-considerations-card-design.md diff --git a/docs/superpowers/specs/2026-06-22-observatoire-considerations-card-design.md b/docs/superpowers/specs/2026-06-22-observatoire-considerations-card-design.md new file mode 100644 index 0000000..74dfff2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-observatoire-considerations-card-design.md @@ -0,0 +1,112 @@ +# Tuile « Considérations sociales et environnementales » — Observatoire + +## Objectif + +Ajouter dans `/observatoire` une tuile (card) qui visualise, à l'aide de barres +de progression « plus ou moins remplies », la part des marchés publics filtrés +qui comportent **au moins une considération sociale** et la part qui comportent +**au moins une considération environnementale**. + +La tuile s'insère juste **après la tuile « Type d'achat »**, avec le même style +que les autres cards. + +## Données + +Colonnes concernées (type `String`, valeurs libres potentiellement composées) : + +- `considerationsSociales` +- `considerationsEnvironnementales` + +Exemples de valeurs : `Sans objet`, `Clause sociale`, `Critère social`, +`Marché réservé`, `Clause environnementale`, `Critère environnemental`, +`Pas de considération sociale`, `null`, ou des combinaisons +(`Critère social, Clause sociale`). + +### Définition « au moins une considération » + +Un marché compte comme ayant une considération si la valeur de la colonne +**contient** l'un des mots-clés (insensible à la casse) : + +- `Clause` +- `Critère` +- `Marché réservé` + +Regex utilisée : `(?i)Clause|Critère|Marché réservé`. + +Conséquence (validée avec l'utilisateur) : **`Marché réservé` compte comme +considération sociale**. Les valeurs `Sans objet`, `Pas de considération…` et +`null` ne contiennent aucun de ces mots-clés et ne comptent donc pas. + +### Calcul du pourcentage + +- **Dédoublonnage par `uid`** : un marché est compté une seule fois même s'il + apparaît sur plusieurs lignes (plusieurs titulaires). On prend la première + valeur de chaque colonne par `uid`. +- **Dénominateur** : **tous** les marchés filtrés (y compris `Sans objet` et + non renseignés) — validé avec l'utilisateur. +- **Numérateur** : nombre de marchés (uid distincts) dont la valeur de colonne + satisfait la regex. +- `pourcentage = round(100 * numérateur / dénominateur)` ; si dénominateur = 0, + pourcentage = 0. + +## Composant visuel + +Nouvelle fonction `get_considerations_card_content(lff: pl.LazyFrame)` dans +`src/figures.py`, renvoyant un `html.Div` contenant deux barres `dbc.Progress` +empilées : + +| Considération | Couleur (px.colors.qualitative.Safe) | Valeur RGB | +| ----------------- | ------------------------------------ | -------------------- | +| Sociales | index 1 (rouge) | `rgb(204, 102, 119)` | +| Environnementales | index 3 (vert) | `rgb(17, 119, 51)` | + +Chaque barre : + +- `dbc.Progress(value=pourcentage, label=f"{pourcentage} %", style={"backgroundColor": })` +- précédée d'un libellé (`Sociales` / `Environnementales`) et suivie du nombre + de marchés concernés (`N marchés`), formaté avec `format_number`. + +### Robustesse (colonne absente) + +`tests/test.parquet` peut ne pas contenir ces colonnes. La fonction vérifie la +présence de chaque colonne via `lff.collect_schema().names()` ; si une colonne +manque, son pourcentage et son compte valent 0 (pas d'exception), à l'image de +`get_distance_histogram`. + +## Intégration + +Dans `src/pages/observatoire.py`, fonction `_compute_dashboard_children` : + +```python +donut_marche_type = make_donut(lff, "type", per_uid=True, nulls="?") +cards.append(make_card(title="Type d'achat", ...)) + +# NOUVEAU +considerations = get_considerations_card_content(lff) +cards.append( + make_card( + title="Considérations sociales et environnementales", + subtitle="part des marchés concernés", + fig=considerations, + ) +) +``` + +`make_card` utilise ses dimensions par défaut (`lg=6, xl=4`), comme la tuile +« Type d'achat ». + +Import à ajouter : `get_considerations_card_content` depuis `src.figures`. + +## Tests + +- Test unitaire de `get_considerations_card_content` sur un petit `LazyFrame` + construit en mémoire couvrant : valeur avec considération, `Sans objet`, + `null`, `Marché réservé`, doublon de `uid`. Vérifier les pourcentages + attendus. +- Cas colonne absente → 0 % sans exception. + +## Hors périmètre (YAGNI) + +- Pas de tooltip détaillé sur les types de considérations. +- Pas de graphe de répartition par type (clause vs critère). +- Pas de nouveau filtre (les filtres existants `social`/`env` restent inchangés). From 2ce762b6cc5d431b3af355cbd2f4a451efdcbaf0 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 18:11:45 +0200 Subject: [PATCH 111/151] =?UTF-8?q?docs:=20plan=20tuile=20consid=C3=A9rati?= =?UTF-8?q?ons=20sociales/environnementales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...-06-22-observatoire-considerations-card.md | 426 ++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-observatoire-considerations-card.md diff --git a/docs/superpowers/plans/2026-06-22-observatoire-considerations-card.md b/docs/superpowers/plans/2026-06-22-observatoire-considerations-card.md new file mode 100644 index 0000000..7d0234b --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-observatoire-considerations-card.md @@ -0,0 +1,426 @@ +# Tuile « Considérations sociales et environnementales » — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ajouter dans `/observatoire`, juste après la tuile « Type d'achat », une tuile montrant via deux barres de progression la part des marchés filtrés comportant au moins une considération sociale (rouge) et au moins une considération environnementale (vert). + +**Architecture:** Une fonction pure de calcul (`compute_considerations_stats`) dans `src/figures.py`, testée directement, alimente une fonction de rendu (`get_considerations_card_content`) qui produit un `html.Div` de deux `dbc.Progress`. La tuile est ajoutée dans `_compute_dashboard_children` via le `make_card` existant. + +**Tech Stack:** Polars (LazyFrame), Dash / Dash Bootstrap Components (`dbc.Progress`), pytest. + +## Global Constraints + +- Imports internes toujours préfixés `src.` (ex. `from src.figures import ...`), jamais `figures` ou `utils`. +- Dédoublonnage **par `uid`** : un marché compté une fois (première valeur par `uid`). +- « Au moins une considération » = la valeur de colonne **contient** (insensible casse) `Clause`, `Critère` ou `Marché réservé`. Regex exacte : `(?i)Clause|Critère|Marché réservé`. +- Dénominateur = **tous** les marchés filtrés (uid distincts), y compris `Sans objet` et non renseignés. +- `pct = round(100 * numérateur / dénominateur)` ; si dénominateur = 0 → `pct = 0`. +- Couleurs issues de `px.colors.qualitative.Safe` : sociales = `rgb(204, 102, 119)` (index 1, rouge) ; environnementales = `rgb(17, 119, 51)` (index 3, vert). +- Robustesse : si une colonne `considerations*` est absente du schéma, son `(count, pct)` vaut `(0, 0)` sans exception. + +--- + +### Task 1: Fonction de calcul `compute_considerations_stats` + +**Files:** + +- Modify: `src/figures.py` (ajouter la fonction après `get_dashboard_summary_table`, ~ ligne 729) +- Test: `tests/test_figures.py` (créer) + +**Interfaces:** + +- Consumes: rien (fonction pure prenant un `pl.LazyFrame`). +- Produces: `compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]` renvoyant `{"sociales": (count, pct), "environnementales": (count, pct)}` où `count` = nombre de marchés (uid distincts) avec au moins une considération et `pct` = pourcentage entier sur le total des uid distincts. + +- [ ] **Step 1: Write the failing test** + +Créer `tests/test_figures.py` : + +```python +import polars as pl + + +def _make_lff(rows): + return pl.LazyFrame(rows) + + +def test_compute_considerations_stats_basic(): + from src.figures import compute_considerations_stats + + lff = _make_lff( + [ + # uid u1 : social oui (Clause), env non (Sans objet) + {"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"}, + # uid u2 : social non (Sans objet), env oui (Critère) + {"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Critère environnemental"}, + # uid u3 : social oui (Marché réservé compte), env null + {"uid": "u3", "considerationsSociales": "Marché réservé", "considerationsEnvironnementales": None}, + # uid u4 : aucune considération + {"uid": "u4", "considerationsSociales": "Pas de considération sociale", "considerationsEnvironnementales": "Sans objet"}, + ] + ) + + stats = compute_considerations_stats(lff) + + # 4 marchés au total. Social : u1, u3 -> 2/4 = 50%. Env : u2 -> 1/4 = 25%. + assert stats["sociales"] == (2, 50) + assert stats["environnementales"] == (1, 25) + + +def test_compute_considerations_stats_dedup_per_uid(): + from src.figures import compute_considerations_stats + + lff = _make_lff( + [ + # uid u1 présent 2 fois (2 titulaires) -> compté une seule fois + {"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"}, + {"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"}, + {"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Sans objet"}, + ] + ) + + stats = compute_considerations_stats(lff) + + # 2 marchés distincts. Social : u1 -> 1/2 = 50%. + assert stats["sociales"] == (1, 50) + assert stats["environnementales"] == (0, 0) + + +def test_compute_considerations_stats_missing_column(): + from src.figures import compute_considerations_stats + + lff = _make_lff( + [ + {"uid": "u1", "considerationsSociales": "Clause sociale"}, + {"uid": "u2", "considerationsSociales": "Sans objet"}, + ] + ) + + stats = compute_considerations_stats(lff) + + # Colonne env absente -> (0, 0) sans exception. Social : 1/2 = 50%. + assert stats["sociales"] == (1, 50) + assert stats["environnementales"] == (0, 0) + + +def test_compute_considerations_stats_empty(): + from src.figures import compute_considerations_stats + + lff = pl.LazyFrame( + { + "uid": pl.Series([], dtype=pl.String), + "considerationsSociales": pl.Series([], dtype=pl.String), + "considerationsEnvironnementales": pl.Series([], dtype=pl.String), + } + ) + + stats = compute_considerations_stats(lff) + + assert stats["sociales"] == (0, 0) + assert stats["environnementales"] == (0, 0) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_figures.py -v` +Expected: FAIL avec `ImportError: cannot import name 'compute_considerations_stats'`. + +- [ ] **Step 3: Write minimal implementation** + +Dans `src/figures.py`, ajouter après `get_dashboard_summary_table` (avant `make_card`) : + +```python +CONSIDERATIONS_REGEX = r"(?i)Clause|Critère|Marché réservé" +CONSIDERATIONS_COLUMNS = { + "sociales": "considerationsSociales", + "environnementales": "considerationsEnvironnementales", +} + + +def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]: + """Part des marchés (uid distincts) ayant au moins une considération. + + Renvoie {"sociales": (count, pct), "environnementales": (count, pct)}. + Dénominateur = tous les uid distincts. Colonne absente -> (0, 0). + """ + names = lff.collect_schema().names() + present = { + key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names + } + + stats = {key: (0, 0) for key in CONSIDERATIONS_COLUMNS} + + if not present: + return stats + + agg = ( + lff.select(["uid"] + list(present.values())) + .group_by("uid") + .agg([pl.col(col).first() for col in present.values()]) + .collect(engine="streaming") + ) + + total = agg.height + if total == 0: + return stats + + for key, col in present.items(): + count = agg.filter( + pl.col(col).str.contains(CONSIDERATIONS_REGEX) + ).height + pct = round(100 * count / total) + stats[key] = (count, pct) + + return stats +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_figures.py -v` +Expected: 4 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/figures.py tests/test_figures.py +git commit -m "feat(observatoire): calcul part marchés avec considération sociale/env + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 2: Rendu de la tuile `get_considerations_card_content` + +**Files:** + +- Modify: `src/figures.py` (ajouter après `compute_considerations_stats`) +- Test: `tests/test_figures.py` (ajouter au fichier de la Task 1) + +**Interfaces:** + +- Consumes: `compute_considerations_stats(lff) -> dict[str, tuple[int, int]]` (Task 1) ; `format_number` (déjà importé en tête de `src/figures.py` : `from src.utils.table import add_links, format_number, setup_table_columns`). +- Produces: `get_considerations_card_content(lff: pl.LazyFrame) -> html.Div` : un `html.Div` contenant deux blocs (sociales, environnementales), chacun avec un libellé, un `dbc.Progress` coloré rempli au pourcentage, et le nombre de marchés. + +- [ ] **Step 1: Write the failing test** + +Ajouter à `tests/test_figures.py` : + +```python +def test_get_considerations_card_content_returns_two_progress_bars(): + import dash_bootstrap_components as dbc + from dash import html + + from src.figures import get_considerations_card_content + + lff = pl.LazyFrame( + [ + {"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"}, + {"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Critère environnemental"}, + ] + ) + + div = get_considerations_card_content(lff) + + assert isinstance(div, html.Div) + + # Récupère récursivement tous les dbc.Progress + def find_progress(component, found): + children = getattr(component, "children", None) + if isinstance(component, dbc.Progress): + found.append(component) + if isinstance(children, (list, tuple)): + for c in children: + find_progress(c, found) + elif children is not None: + find_progress(children, found) + return found + + bars = find_progress(div, []) + assert len(bars) == 2 + + # Sociales (rouge) : u1 -> 50%. Environnementales (vert) : u2 -> 50%. + social_bar, env_bar = bars[0], bars[1] + assert social_bar.value == 50 + assert social_bar.label == "50 %" + assert social_bar.style["backgroundColor"] == "rgb(204, 102, 119)" + assert env_bar.value == 50 + assert env_bar.label == "50 %" + assert env_bar.style["backgroundColor"] == "rgb(17, 119, 51)" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_figures.py::test_get_considerations_card_content_returns_two_progress_bars -v` +Expected: FAIL avec `ImportError: cannot import name 'get_considerations_card_content'`. + +- [ ] **Step 3: Write minimal implementation** + +Dans `src/figures.py`, ajouter après `compute_considerations_stats` : + +```python +CONSIDERATIONS_DISPLAY = [ + # (clé, libellé, couleur Safe) + ("sociales", "Sociales", "rgb(204, 102, 119)"), + ("environnementales", "Environnementales", "rgb(17, 119, 51)"), +] + + +def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div: + """Deux barres de progression : part des marchés avec considération.""" + stats = compute_considerations_stats(lff) + + blocks = [] + for key, label, color in CONSIDERATIONS_DISPLAY: + count, pct = stats[key] + blocks.append( + html.Div( + className="mb-3", + children=[ + html.Div( + className="d-flex justify-content-between", + children=[ + html.Span(label), + html.Span( + f"{format_number(count)} marchés", + className="text-muted", + ), + ], + ), + dbc.Progress( + value=pct, + label=f"{pct} %", + style={"backgroundColor": color}, + ), + ], + ) + ) + + return html.Div(children=blocks) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_figures.py -v` +Expected: tous les tests PASS (5 au total). + +- [ ] **Step 5: Commit** + +```bash +git add src/figures.py tests/test_figures.py +git commit -m "feat(observatoire): tuile considérations en barres de progression + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3: Intégration dans l'Observatoire + +**Files:** + +- Modify: `src/pages/observatoire.py` (import ~ lignes 20-31 ; appel dans `_compute_dashboard_children` après le bloc « Type d'achat », ~ ligne 723) + +**Interfaces:** + +- Consumes: `get_considerations_card_content(lff) -> html.Div` (Task 2) ; `make_card` (déjà importé). +- Produces: une nouvelle `dbc.Col` (card) insérée dans la liste `cards` entre « Type d'achat » et « Distance acheteur–titulaire ». + +- [ ] **Step 1: Ajouter l'import** + +Dans `src/pages/observatoire.py`, dans le bloc `from src.figures import (...)` (lignes 20-31), ajouter `get_considerations_card_content` en respectant l'ordre alphabétique existant (après `get_barchart_sources`) : + +```python +from src.figures import ( + DataTable, + get_barchart_sources, + get_considerations_card_content, + get_dashboard_summary_table, + get_distance_histogram, + get_duplicate_matrix, + get_geographic_maps, + get_top_org_table, + make_card, + make_column_picker, + make_donut, +) +``` + +- [ ] **Step 2: Insérer la tuile après « Type d'achat »** + +Dans `_compute_dashboard_children`, juste après le `cards.append(...)` du donut « Type d'achat » (qui se termine ligne ~723) et avant `distance_histogram = ...`, insérer : + +```python + considerations_content = get_considerations_card_content(lff) + cards.append( + make_card( + title="Considérations sociales et environnementales", + subtitle="part des marchés concernés", + fig=considerations_content, + ) + ) +``` + +Le bloc résultant doit ressembler à : + +```python + donut_marche_type = make_donut(lff, "type", per_uid=True, nulls="?") + cards.append( + make_card( + title="Type d'achat", + subtitle="en nombre de marchés attribués", + fig=donut_marche_type, + ) + ) + + considerations_content = get_considerations_card_content(lff) + cards.append( + make_card( + title="Considérations sociales et environnementales", + subtitle="part des marchés concernés", + fig=considerations_content, + ) + ) + + distance_histogram = get_distance_histogram(lff) +``` + +- [ ] **Step 3: Vérifier que la page se charge (test d'import/rendu)** + +Run: `uv run pytest tests/test_page_loads.py -v` +Expected: PASS (aucune régression sur le chargement des pages). Si `tests/test_page_loads.py` ne couvre pas `/observatoire`, lancer en complément : + +Run: `uv run python -c "import src.pages.observatoire"` +Expected: aucune erreur d'import. + +- [ ] **Step 4: Lancer l'ensemble de la suite figures + observatoire** + +Run: `uv run pytest tests/test_figures.py -v` +Expected: tous PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/observatoire.py +git commit -m "feat(observatoire): afficher la tuile considérations après Type d'achat + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Self-Review + +**Spec coverage :** + +- Définition « au moins une considération » (regex incl. `Marché réservé`) → Task 1, `CONSIDERATIONS_REGEX`, tests `basic`. +- Dédoublonnage par `uid` → Task 1, test `dedup_per_uid`. +- Dénominateur = tous les marchés filtrés → Task 1 (`total = agg.height`), tests. +- Colonne absente → 0 % → Task 1, test `missing_column` ; cas vide → test `empty`. +- Deux barres `dbc.Progress`, couleurs Safe rouge/vert, labels `XX %` + `N marchés` → Task 2. +- Insertion après « Type d'achat », dimensions par défaut `make_card` → Task 3. +- Hors périmètre (pas de tooltip, pas de filtre) → respecté, rien d'ajouté. + +**Placeholder scan :** aucun TODO/TBD ; tout le code est fourni. + +**Type consistency :** `compute_considerations_stats` renvoie `dict[str, tuple[int, int]]` clés `sociales`/`environnementales`, consommé tel quel par `get_considerations_card_content` (Task 2) ; `get_considerations_card_content` renvoie `html.Div`, passé à `make_card(fig=...)` (Task 3). Cohérent. From a85baca40814f068f082e34c13c5e72846b7e4ad Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 18:16:34 +0200 Subject: [PATCH 112/151] =?UTF-8?q?feat(observatoire):=20calcul=20et=20ren?= =?UTF-8?q?du=20tuile=20consid=C3=A9rations=20sociales/env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute compute_considerations_stats (dédoublonnage par uid, regex Clause|Critère|Marché réservé, colonne absente → 0%) et get_considerations_card_content (deux dbc.Progress colorés Safe). Co-Authored-By: Claude Sonnet 4.6 --- src/figures.py | 80 +++++++++++++++++++++ tests/test_figures.py | 159 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 tests/test_figures.py diff --git a/src/figures.py b/src/figures.py index 966fd9b..59384d0 100644 --- a/src/figures.py +++ b/src/figures.py @@ -728,6 +728,86 @@ def get_dashboard_summary_table(dff, dff_per_uid, nb_marches): return summary_table +CONSIDERATIONS_REGEX = r"(?i)Clause|Critère|Marché réservé" +CONSIDERATIONS_COLUMNS = { + "sociales": "considerationsSociales", + "environnementales": "considerationsEnvironnementales", +} + + +def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]: + """Part des marchés (uid distincts) ayant au moins une considération. + + Renvoie {"sociales": (count, pct), "environnementales": (count, pct)}. + Dénominateur = tous les uid distincts. Colonne absente -> (0, 0). + """ + names = lff.collect_schema().names() + present = {key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names} + + stats = {key: (0, 0) for key in CONSIDERATIONS_COLUMNS} + + if not present: + return stats + + agg = ( + lff.select(["uid"] + list(present.values())) + .group_by("uid") + .agg([pl.col(col).first() for col in present.values()]) + .collect(engine="streaming") + ) + + total = agg.height + if total == 0: + return stats + + for key, col in present.items(): + count = agg.filter(pl.col(col).str.contains(CONSIDERATIONS_REGEX)).height + pct = round(100 * count / total) + stats[key] = (count, pct) + + return stats + + +CONSIDERATIONS_DISPLAY = [ + # (clé, libellé, couleur Safe) + ("sociales", "Sociales", "rgb(204, 102, 119)"), + ("environnementales", "Environnementales", "rgb(17, 119, 51)"), +] + + +def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div: + """Deux barres de progression : part des marchés avec considération.""" + stats = compute_considerations_stats(lff) + + blocks = [] + for key, label, color in CONSIDERATIONS_DISPLAY: + count, pct = stats[key] + blocks.append( + html.Div( + className="mb-3", + children=[ + html.Div( + className="d-flex justify-content-between", + children=[ + html.Span(label), + html.Span( + f"{format_number(count)} marchés", + className="text-muted", + ), + ], + ), + dbc.Progress( + value=pct, + label=f"{pct} %", + style={"backgroundColor": color}, + ), + ], + ) + ) + + return html.Div(children=blocks) + + def make_card( title: str, subtitle=None, fig=None, paragraphs=None, lg=6, xl=4 ) -> dbc.Col: diff --git a/tests/test_figures.py b/tests/test_figures.py new file mode 100644 index 0000000..c7a7922 --- /dev/null +++ b/tests/test_figures.py @@ -0,0 +1,159 @@ +import polars as pl + + +def _make_lff(rows): + return pl.LazyFrame(rows) + + +def test_compute_considerations_stats_basic(): + from src.figures import compute_considerations_stats + + lff = _make_lff( + [ + # uid u1 : social oui (Clause), env non (Sans objet) + { + "uid": "u1", + "considerationsSociales": "Clause sociale", + "considerationsEnvironnementales": "Sans objet", + }, + # uid u2 : social non (Sans objet), env oui (Critère) + { + "uid": "u2", + "considerationsSociales": "Sans objet", + "considerationsEnvironnementales": "Critère environnemental", + }, + # uid u3 : social oui (Marché réservé compte), env null + { + "uid": "u3", + "considerationsSociales": "Marché réservé", + "considerationsEnvironnementales": None, + }, + # uid u4 : aucune considération + { + "uid": "u4", + "considerationsSociales": "Pas de considération sociale", + "considerationsEnvironnementales": "Sans objet", + }, + ] + ) + + stats = compute_considerations_stats(lff) + + # 4 marchés au total. Social : u1, u3 -> 2/4 = 50%. Env : u2 -> 1/4 = 25%. + assert stats["sociales"] == (2, 50) + assert stats["environnementales"] == (1, 25) + + +def test_compute_considerations_stats_dedup_per_uid(): + from src.figures import compute_considerations_stats + + lff = _make_lff( + [ + # uid u1 présent 2 fois (2 titulaires) -> compté une seule fois + { + "uid": "u1", + "considerationsSociales": "Clause sociale", + "considerationsEnvironnementales": "Sans objet", + }, + { + "uid": "u1", + "considerationsSociales": "Clause sociale", + "considerationsEnvironnementales": "Sans objet", + }, + { + "uid": "u2", + "considerationsSociales": "Sans objet", + "considerationsEnvironnementales": "Sans objet", + }, + ] + ) + + stats = compute_considerations_stats(lff) + + # 2 marchés distincts. Social : u1 -> 1/2 = 50%. + assert stats["sociales"] == (1, 50) + assert stats["environnementales"] == (0, 0) + + +def test_compute_considerations_stats_missing_column(): + from src.figures import compute_considerations_stats + + lff = _make_lff( + [ + {"uid": "u1", "considerationsSociales": "Clause sociale"}, + {"uid": "u2", "considerationsSociales": "Sans objet"}, + ] + ) + + stats = compute_considerations_stats(lff) + + # Colonne env absente -> (0, 0) sans exception. Social : 1/2 = 50%. + assert stats["sociales"] == (1, 50) + assert stats["environnementales"] == (0, 0) + + +def test_compute_considerations_stats_empty(): + from src.figures import compute_considerations_stats + + lff = pl.LazyFrame( + { + "uid": pl.Series([], dtype=pl.String), + "considerationsSociales": pl.Series([], dtype=pl.String), + "considerationsEnvironnementales": pl.Series([], dtype=pl.String), + } + ) + + stats = compute_considerations_stats(lff) + + assert stats["sociales"] == (0, 0) + assert stats["environnementales"] == (0, 0) + + +def test_get_considerations_card_content_returns_two_progress_bars(): + import dash_bootstrap_components as dbc + from dash import html + + from src.figures import get_considerations_card_content + + lff = pl.LazyFrame( + [ + { + "uid": "u1", + "considerationsSociales": "Clause sociale", + "considerationsEnvironnementales": "Sans objet", + }, + { + "uid": "u2", + "considerationsSociales": "Sans objet", + "considerationsEnvironnementales": "Critère environnemental", + }, + ] + ) + + div = get_considerations_card_content(lff) + + assert isinstance(div, html.Div) + + # Récupère récursivement tous les dbc.Progress + def find_progress(component, found): + children = getattr(component, "children", None) + if isinstance(component, dbc.Progress): + found.append(component) + if isinstance(children, (list, tuple)): + for c in children: + find_progress(c, found) + elif children is not None: + find_progress(children, found) + return found + + bars = find_progress(div, []) + assert len(bars) == 2 + + # Sociales (rouge) : u1 -> 50%. Environnementales (vert) : u2 -> 50%. + social_bar, env_bar = bars[0], bars[1] + assert social_bar.value == 50 + assert social_bar.label == "50 %" + assert social_bar.style["backgroundColor"] == "rgb(204, 102, 119)" + assert env_bar.value == 50 + assert env_bar.label == "50 %" + assert env_bar.style["backgroundColor"] == "rgb(17, 119, 51)" From 5586db23aa03d697699e30619db158938d69b4ca Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 18:17:27 +0200 Subject: [PATCH 113/151] =?UTF-8?q?feat(observatoire):=20afficher=20la=20t?= =?UTF-8?q?uile=20consid=C3=A9rations=20apr=C3=A8s=20Type=20d'achat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/pages/observatoire.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/pages/observatoire.py b/src/pages/observatoire.py index 716037e..7dc85f9 100644 --- a/src/pages/observatoire.py +++ b/src/pages/observatoire.py @@ -20,6 +20,7 @@ from src.db import schema from src.figures import ( DataTable, get_barchart_sources, + get_considerations_card_content, get_dashboard_summary_table, get_distance_histogram, get_duplicate_matrix, @@ -722,6 +723,15 @@ def _compute_dashboard_children(filter_params_normalized: tuple): ) ) + considerations_content = get_considerations_card_content(lff) + cards.append( + make_card( + title="Considérations sociales et environnementales", + subtitle="part des marchés concernés", + fig=considerations_content, + ) + ) + distance_histogram = get_distance_histogram(lff) cards.append( make_card( From eab06eedae9a0ff4fdb9c3c6a5b877295ee0858d Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 18:45:33 +0200 Subject: [PATCH 114/151] =?UTF-8?q?fix(observatoire):=20couleurs=20barres?= =?UTF-8?q?=20consid=C3=A9rations=20palette=20Plotly=20Safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utilise les codes hex #CC6677 (sociales) et #117733 (env) via le prop color de dbc.Progress, qui accepte les valeurs hex. Co-Authored-By: Claude Sonnet 4.6 --- src/figures.py | 7 +++---- tests/test_figures.py | 11 +++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/figures.py b/src/figures.py index 59384d0..eb94373 100644 --- a/src/figures.py +++ b/src/figures.py @@ -769,9 +769,8 @@ def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int] CONSIDERATIONS_DISPLAY = [ - # (clé, libellé, couleur Safe) - ("sociales", "Sociales", "rgb(204, 102, 119)"), - ("environnementales", "Environnementales", "rgb(17, 119, 51)"), + ("sociales", "Sociales", "#CC6677"), + ("environnementales", "Environnementales", "#117733"), ] @@ -799,7 +798,7 @@ def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div: dbc.Progress( value=pct, label=f"{pct} %", - style={"backgroundColor": color}, + color=color, ), ], ) diff --git a/tests/test_figures.py b/tests/test_figures.py index c7a7922..d29de4a 100644 --- a/tests/test_figures.py +++ b/tests/test_figures.py @@ -134,26 +134,25 @@ def test_get_considerations_card_content_returns_two_progress_bars(): assert isinstance(div, html.Div) - # Récupère récursivement tous les dbc.Progress def find_progress(component, found): - children = getattr(component, "children", None) if isinstance(component, dbc.Progress): found.append(component) + children = getattr(component, "children", None) if isinstance(children, (list, tuple)): for c in children: find_progress(c, found) - elif children is not None: + elif children is not None and not isinstance(children, str): find_progress(children, found) return found bars = find_progress(div, []) assert len(bars) == 2 - # Sociales (rouge) : u1 -> 50%. Environnementales (vert) : u2 -> 50%. + # Sociales (#CC6677) : u1 -> 50%. Environnementales (#117733) : u2 -> 50%. social_bar, env_bar = bars[0], bars[1] assert social_bar.value == 50 assert social_bar.label == "50 %" - assert social_bar.style["backgroundColor"] == "rgb(204, 102, 119)" + assert social_bar.color == "#CC6677" assert env_bar.value == 50 assert env_bar.label == "50 %" - assert env_bar.style["backgroundColor"] == "rgb(17, 119, 51)" + assert env_bar.color == "#117733" From 8efd9b45eb55b82726c798b939ebd9f5e3e71329 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 18:46:37 +0200 Subject: [PATCH 115/151] =?UTF-8?q?fix(observatoire):=20texte=20%=20blanc?= =?UTF-8?q?=20sur=20les=20barres=20de=20consid=C3=A9rations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/figures.py | 10 +++++++--- tests/test_figures.py | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/figures.py b/src/figures.py index eb94373..767a579 100644 --- a/src/figures.py +++ b/src/figures.py @@ -796,9 +796,13 @@ def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div: ], ), dbc.Progress( - value=pct, - label=f"{pct} %", - color=color, + dbc.Progress( + value=pct, + label=f"{pct} %", + bar=True, + color=color, + style={"color": "white"}, + ), ), ], ) diff --git a/tests/test_figures.py b/tests/test_figures.py index d29de4a..53c6365 100644 --- a/tests/test_figures.py +++ b/tests/test_figures.py @@ -145,14 +145,18 @@ def test_get_considerations_card_content_returns_two_progress_bars(): find_progress(children, found) return found - bars = find_progress(div, []) - assert len(bars) == 2 + all_bars = find_progress(div, []) + # Structure imbriquée : outer (track) + inner (bar=True, couleur + texte blanc) + inner_bars = [b for b in all_bars if getattr(b, "bar", False)] + assert len(inner_bars) == 2 # Sociales (#CC6677) : u1 -> 50%. Environnementales (#117733) : u2 -> 50%. - social_bar, env_bar = bars[0], bars[1] + social_bar, env_bar = inner_bars[0], inner_bars[1] assert social_bar.value == 50 assert social_bar.label == "50 %" assert social_bar.color == "#CC6677" + assert social_bar.style["color"] == "white" assert env_bar.value == 50 assert env_bar.label == "50 %" assert env_bar.color == "#117733" + assert env_bar.style["color"] == "white" From 19e181734c77db68d5fca9610ab05a74d8b8f3cc Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 18:55:38 +0200 Subject: [PATCH 116/151] =?UTF-8?q?feat(observatoire):=20ajouter=20barres?= =?UTF-8?q?=20'valeur=20renseign=C3=A9e'=20dans=20tuile=20consid=C3=A9rati?= =?UTF-8?q?ons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Affiche 4 barres groupées par type (sociales/env) : une pour les marchés avec au moins une considération, une pour ceux qui ont une valeur renseignée (non nulle, y compris 'Sans objet'). Co-Authored-By: Claude Sonnet 4.6 --- src/figures.py | 56 ++++++++++++++++++++++++++++--------------- tests/test_figures.py | 40 +++++++++++++++++++++---------- 2 files changed, 64 insertions(+), 32 deletions(-) diff --git a/src/figures.py b/src/figures.py index 767a579..87faba4 100644 --- a/src/figures.py +++ b/src/figures.py @@ -738,13 +738,18 @@ CONSIDERATIONS_COLUMNS = { def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]: """Part des marchés (uid distincts) ayant au moins une considération. - Renvoie {"sociales": (count, pct), "environnementales": (count, pct)}. + Renvoie pour chaque clé (sociales, environnementales) : + - (count, pct) : marchés avec au moins une considération (regex) + - (count, pct) suffixé _renseignees : marchés avec une valeur non nulle Dénominateur = tous les uid distincts. Colonne absente -> (0, 0). """ names = lff.collect_schema().names() present = {key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names} - stats = {key: (0, 0) for key in CONSIDERATIONS_COLUMNS} + stats: dict[str, tuple[int, int]] = {} + for key in CONSIDERATIONS_COLUMNS: + stats[key] = (0, 0) + stats[f"{key}_renseignees"] = (0, 0) if not present: return stats @@ -761,26 +766,37 @@ def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int] return stats for key, col in present.items(): - count = agg.filter(pl.col(col).str.contains(CONSIDERATIONS_REGEX)).height - pct = round(100 * count / total) - stats[key] = (count, pct) + count_pos = agg.filter(pl.col(col).str.contains(CONSIDERATIONS_REGEX)).height + count_ren = agg.filter(pl.col(col).is_not_null()).height + stats[key] = (count_pos, round(100 * count_pos / total)) + stats[f"{key}_renseignees"] = (count_ren, round(100 * count_ren / total)) return stats +# (clé, libellé, couleur principale, couleur renseignée) CONSIDERATIONS_DISPLAY = [ - ("sociales", "Sociales", "#CC6677"), - ("environnementales", "Environnementales", "#117733"), + ("sociales", "Sociales", "#CC6677", "#E5B2BB"), + ("environnementales", "Environnementales", "#117733", "#88BB99"), ] +def _progress_bar(pct: int, color: str) -> dbc.Progress: + return dbc.Progress( + dbc.Progress( + value=pct, label=f"{pct} %", bar=True, color=color, style={"color": "white"} + ), + ) + + def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div: - """Deux barres de progression : part des marchés avec considération.""" + """Quatre barres groupées par type : considération positive + valeur renseignée.""" stats = compute_considerations_stats(lff) blocks = [] - for key, label, color in CONSIDERATIONS_DISPLAY: - count, pct = stats[key] + for key, label, color, color_ren in CONSIDERATIONS_DISPLAY: + count_pos, pct_pos = stats[key] + count_ren, pct_ren = stats[f"{key}_renseignees"] blocks.append( html.Div( className="mb-3", @@ -790,20 +806,22 @@ def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div: children=[ html.Span(label), html.Span( - f"{format_number(count)} marchés", + f"{format_number(count_pos)} marchés", className="text-muted", ), ], ), - dbc.Progress( - dbc.Progress( - value=pct, - label=f"{pct} %", - bar=True, - color=color, - style={"color": "white"}, - ), + _progress_bar(pct_pos, color), + html.Div( + className="d-flex justify-content-end mt-1", + children=[ + html.Span( + f"{format_number(count_ren)} marchés (renseignée)", + className="text-muted", + ), + ], ), + _progress_bar(pct_ren, color_ren), ], ) ) diff --git a/tests/test_figures.py b/tests/test_figures.py index 53c6365..3ffb42b 100644 --- a/tests/test_figures.py +++ b/tests/test_figures.py @@ -42,6 +42,9 @@ def test_compute_considerations_stats_basic(): # 4 marchés au total. Social : u1, u3 -> 2/4 = 50%. Env : u2 -> 1/4 = 25%. assert stats["sociales"] == (2, 50) assert stats["environnementales"] == (1, 25) + # Renseignées : social u1/u2/u3/u4 non null -> 4/4=100%. Env : u1/u2/u4 non null -> 3/4=75%. + assert stats["sociales_renseignees"] == (4, 100) + assert stats["environnementales_renseignees"] == (3, 75) def test_compute_considerations_stats_dedup_per_uid(): @@ -73,6 +76,8 @@ def test_compute_considerations_stats_dedup_per_uid(): # 2 marchés distincts. Social : u1 -> 1/2 = 50%. assert stats["sociales"] == (1, 50) assert stats["environnementales"] == (0, 0) + assert stats["sociales_renseignees"] == (2, 100) + assert stats["environnementales_renseignees"] == (2, 100) def test_compute_considerations_stats_missing_column(): @@ -90,6 +95,8 @@ def test_compute_considerations_stats_missing_column(): # Colonne env absente -> (0, 0) sans exception. Social : 1/2 = 50%. assert stats["sociales"] == (1, 50) assert stats["environnementales"] == (0, 0) + assert stats["sociales_renseignees"] == (2, 100) + assert stats["environnementales_renseignees"] == (0, 0) def test_compute_considerations_stats_empty(): @@ -107,9 +114,11 @@ def test_compute_considerations_stats_empty(): assert stats["sociales"] == (0, 0) assert stats["environnementales"] == (0, 0) + assert stats["sociales_renseignees"] == (0, 0) + assert stats["environnementales_renseignees"] == (0, 0) -def test_get_considerations_card_content_returns_two_progress_bars(): +def test_get_considerations_card_content_returns_four_progress_bars(): import dash_bootstrap_components as dbc from dash import html @@ -146,17 +155,22 @@ def test_get_considerations_card_content_returns_two_progress_bars(): return found all_bars = find_progress(div, []) - # Structure imbriquée : outer (track) + inner (bar=True, couleur + texte blanc) inner_bars = [b for b in all_bars if getattr(b, "bar", False)] - assert len(inner_bars) == 2 + # 4 barres internes : 2 positives + 2 renseignées + assert len(inner_bars) == 4 - # Sociales (#CC6677) : u1 -> 50%. Environnementales (#117733) : u2 -> 50%. - social_bar, env_bar = inner_bars[0], inner_bars[1] - assert social_bar.value == 50 - assert social_bar.label == "50 %" - assert social_bar.color == "#CC6677" - assert social_bar.style["color"] == "white" - assert env_bar.value == 50 - assert env_bar.label == "50 %" - assert env_bar.color == "#117733" - assert env_bar.style["color"] == "white" + social_pos, social_ren, env_pos, env_ren = inner_bars + # Sociales positives : u1 -> 1/2 = 50% + assert social_pos.value == 50 + assert social_pos.color == "#CC6677" + assert social_pos.style["color"] == "white" + # Sociales renseignées : u1 + u2 non null -> 2/2 = 100% + assert social_ren.value == 100 + assert social_ren.color == "#E5B2BB" + # Environnementales positives : u2 -> 1/2 = 50% + assert env_pos.value == 50 + assert env_pos.color == "#117733" + assert env_pos.style["color"] == "white" + # Environnementales renseignées : u1 (Sans objet) + u2 -> 2/2 = 100% + assert env_ren.value == 100 + assert env_ren.color == "#88BB99" From 26455a7616746c957a962b4478a49a2ec4fa68e8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 21:18:00 +0200 Subject: [PATCH 117/151] =?UTF-8?q?fix(observatoire):=20barre=20renseign?= =?UTF-8?q?=C3=A9e=20=3D=20positifs=20parmi=20les=20march=C3=A9s=20renseig?= =?UTF-8?q?n=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dénominateur = marchés non-null ; numérateur = marchés positifs (regex). Label affiché : "parmi les N marchés renseignés". Co-Authored-By: Claude Sonnet 4.6 --- src/figures.py | 12 +++++++----- tests/test_figures.py | 23 +++++++++++++---------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/figures.py b/src/figures.py index 87faba4..e96946a 100644 --- a/src/figures.py +++ b/src/figures.py @@ -739,9 +739,10 @@ def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int] """Part des marchés (uid distincts) ayant au moins une considération. Renvoie pour chaque clé (sociales, environnementales) : - - (count, pct) : marchés avec au moins une considération (regex) - - (count, pct) suffixé _renseignees : marchés avec une valeur non nulle - Dénominateur = tous les uid distincts. Colonne absente -> (0, 0). + - (count_pos, pct) : positifs / tous les uid distincts + - (count_ren, pct) suffixé _renseignees : non-null uid comme dénominateur, + positifs comme numérateur (= part de positifs parmi les marchés renseignés) + Colonne absente -> (0, 0). """ names = lff.collect_schema().names() present = {key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names} @@ -768,8 +769,9 @@ def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int] for key, col in present.items(): count_pos = agg.filter(pl.col(col).str.contains(CONSIDERATIONS_REGEX)).height count_ren = agg.filter(pl.col(col).is_not_null()).height + pct_pos_ren = round(100 * count_pos / count_ren) if count_ren > 0 else 0 stats[key] = (count_pos, round(100 * count_pos / total)) - stats[f"{key}_renseignees"] = (count_ren, round(100 * count_ren / total)) + stats[f"{key}_renseignees"] = (count_ren, pct_pos_ren) return stats @@ -816,7 +818,7 @@ def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div: className="d-flex justify-content-end mt-1", children=[ html.Span( - f"{format_number(count_ren)} marchés (renseignée)", + f"parmi les {format_number(count_ren)} marchés renseignés", className="text-muted", ), ], diff --git a/tests/test_figures.py b/tests/test_figures.py index 3ffb42b..0ccc82a 100644 --- a/tests/test_figures.py +++ b/tests/test_figures.py @@ -42,9 +42,10 @@ def test_compute_considerations_stats_basic(): # 4 marchés au total. Social : u1, u3 -> 2/4 = 50%. Env : u2 -> 1/4 = 25%. assert stats["sociales"] == (2, 50) assert stats["environnementales"] == (1, 25) - # Renseignées : social u1/u2/u3/u4 non null -> 4/4=100%. Env : u1/u2/u4 non null -> 3/4=75%. - assert stats["sociales_renseignees"] == (4, 100) - assert stats["environnementales_renseignees"] == (3, 75) + # Renseignées : dénominateur = non-null ; numérateur = positifs. + # Social : 4 non-null, 2 positifs -> (4, 50%). Env : 3 non-null, 1 positif -> (3, 33%). + assert stats["sociales_renseignees"] == (4, 50) + assert stats["environnementales_renseignees"] == (3, 33) def test_compute_considerations_stats_dedup_per_uid(): @@ -76,8 +77,9 @@ def test_compute_considerations_stats_dedup_per_uid(): # 2 marchés distincts. Social : u1 -> 1/2 = 50%. assert stats["sociales"] == (1, 50) assert stats["environnementales"] == (0, 0) - assert stats["sociales_renseignees"] == (2, 100) - assert stats["environnementales_renseignees"] == (2, 100) + # Social : 2 non-null, 1 positif -> (2, 50%). Env : 2 non-null, 0 positif -> (2, 0%). + assert stats["sociales_renseignees"] == (2, 50) + assert stats["environnementales_renseignees"] == (2, 0) def test_compute_considerations_stats_missing_column(): @@ -95,7 +97,8 @@ def test_compute_considerations_stats_missing_column(): # Colonne env absente -> (0, 0) sans exception. Social : 1/2 = 50%. assert stats["sociales"] == (1, 50) assert stats["environnementales"] == (0, 0) - assert stats["sociales_renseignees"] == (2, 100) + # Social : 2 non-null, 1 positif -> (2, 50%). Env absente -> (0, 0). + assert stats["sociales_renseignees"] == (2, 50) assert stats["environnementales_renseignees"] == (0, 0) @@ -164,13 +167,13 @@ def test_get_considerations_card_content_returns_four_progress_bars(): assert social_pos.value == 50 assert social_pos.color == "#CC6677" assert social_pos.style["color"] == "white" - # Sociales renseignées : u1 + u2 non null -> 2/2 = 100% - assert social_ren.value == 100 + # Sociales renseignées : 2 non-null, 1 positif (u1) -> 50% + assert social_ren.value == 50 assert social_ren.color == "#E5B2BB" # Environnementales positives : u2 -> 1/2 = 50% assert env_pos.value == 50 assert env_pos.color == "#117733" assert env_pos.style["color"] == "white" - # Environnementales renseignées : u1 (Sans objet) + u2 -> 2/2 = 100% - assert env_ren.value == 100 + # Environnementales renseignées : 2 non-null (u1 "Sans objet", u2), 1 positif (u2) -> 50% + assert env_ren.value == 50 assert env_ren.color == "#88BB99" From ae8292d68d20eeb657aa07cf9691913066cccc78 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 22:02:37 +0200 Subject: [PATCH 118/151] =?UTF-8?q?fix(observatoire):=20filtre=20renseign?= =?UTF-8?q?=C3=A9es=20sur=20!=3D=20'Sans=20objet'=20plut=C3=B4t=20que=20re?= =?UTF-8?q?gex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seule 'Sans objet' est considérée négative parmi les marchés renseignés, cohérent avec le reste des traitements. Co-Authored-By: Claude Sonnet 4.6 --- src/figures.py | 5 ++++- tests/test_figures.py | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/figures.py b/src/figures.py index e96946a..4641c61 100644 --- a/src/figures.py +++ b/src/figures.py @@ -769,7 +769,10 @@ def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int] for key, col in present.items(): count_pos = agg.filter(pl.col(col).str.contains(CONSIDERATIONS_REGEX)).height count_ren = agg.filter(pl.col(col).is_not_null()).height - pct_pos_ren = round(100 * count_pos / count_ren) if count_ren > 0 else 0 + count_pos_ren = agg.filter( + pl.col(col).is_not_null() & (pl.col(col) != "Sans objet") + ).height + pct_pos_ren = round(100 * count_pos_ren / count_ren) if count_ren > 0 else 0 stats[key] = (count_pos, round(100 * count_pos / total)) stats[f"{key}_renseignees"] = (count_ren, pct_pos_ren) diff --git a/tests/test_figures.py b/tests/test_figures.py index 0ccc82a..040c92e 100644 --- a/tests/test_figures.py +++ b/tests/test_figures.py @@ -42,9 +42,10 @@ def test_compute_considerations_stats_basic(): # 4 marchés au total. Social : u1, u3 -> 2/4 = 50%. Env : u2 -> 1/4 = 25%. assert stats["sociales"] == (2, 50) assert stats["environnementales"] == (1, 25) - # Renseignées : dénominateur = non-null ; numérateur = positifs. - # Social : 4 non-null, 2 positifs -> (4, 50%). Env : 3 non-null, 1 positif -> (3, 33%). - assert stats["sociales_renseignees"] == (4, 50) + # Renseignées : dénominateur = non-null ; numérateur = non-null ET != "Sans objet". + # Social : 4 non-null, 3 != "Sans objet" (u1/u3/u4) -> (4, 75%). + # Env : 3 non-null, 1 != "Sans objet" (u2) -> (3, 33%). + assert stats["sociales_renseignees"] == (4, 75) assert stats["environnementales_renseignees"] == (3, 33) From 074716bc160e7183b40b70a34ad55739de30c7ac Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 22:21:37 +0200 Subject: [PATCH 119/151] feat(observatoire): restructurer tuile en 3 barres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Barre 1 (grise) : % marchés avec champs considérations renseignés - Barre 2 (rose) : % positifs parmi les marchés renseignés — sociales - Barre 3 (verte) : % positifs parmi les marchés renseignés — env Co-Authored-By: Claude Sonnet 4.6 --- src/figures.py | 66 ++++++++++++++++++++++------------------- tests/test_figures.py | 69 ++++++++++++++++++------------------------- 2 files changed, 64 insertions(+), 71 deletions(-) diff --git a/src/figures.py b/src/figures.py index 4641c61..ae322ca 100644 --- a/src/figures.py +++ b/src/figures.py @@ -736,20 +736,18 @@ CONSIDERATIONS_COLUMNS = { def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]: - """Part des marchés (uid distincts) ayant au moins une considération. + """Statistiques considérations pour l'observatoire. - Renvoie pour chaque clé (sociales, environnementales) : - - (count_pos, pct) : positifs / tous les uid distincts - - (count_ren, pct) suffixé _renseignees : non-null uid comme dénominateur, - positifs comme numérateur (= part de positifs parmi les marchés renseignés) + Clés renvoyées : + - "champs_renseignes" : (count_ren_sociales, pct / total) + - "{key}_renseignees" : (count_ren, pct positifs parmi renseignés) Colonne absente -> (0, 0). """ names = lff.collect_schema().names() present = {key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names} - stats: dict[str, tuple[int, int]] = {} + stats: dict[str, tuple[int, int]] = {"champs_renseignes": (0, 0)} for key in CONSIDERATIONS_COLUMNS: - stats[key] = (0, 0) stats[f"{key}_renseignees"] = (0, 0) if not present: @@ -767,22 +765,22 @@ def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int] return stats for key, col in present.items(): - count_pos = agg.filter(pl.col(col).str.contains(CONSIDERATIONS_REGEX)).height count_ren = agg.filter(pl.col(col).is_not_null()).height count_pos_ren = agg.filter( pl.col(col).is_not_null() & (pl.col(col) != "Sans objet") ).height pct_pos_ren = round(100 * count_pos_ren / count_ren) if count_ren > 0 else 0 - stats[key] = (count_pos, round(100 * count_pos / total)) stats[f"{key}_renseignees"] = (count_ren, pct_pos_ren) + if key == "sociales": + stats["champs_renseignes"] = (count_ren, round(100 * count_ren / total)) return stats -# (clé, libellé, couleur principale, couleur renseignée) -CONSIDERATIONS_DISPLAY = [ - ("sociales", "Sociales", "#CC6677", "#E5B2BB"), - ("environnementales", "Environnementales", "#117733", "#88BB99"), +# (clé stats, libellé, couleur) +CONSIDERATIONS_RENSEIGNEES = [ + ("sociales_renseignees", "Sociales", "#CC6677"), + ("environnementales_renseignees", "Environnementales", "#117733"), ] @@ -795,13 +793,31 @@ def _progress_bar(pct: int, color: str) -> dbc.Progress: def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div: - """Quatre barres groupées par type : considération positive + valeur renseignée.""" + """Trois barres : champs renseignés + part positive pour sociales et environnementales.""" stats = compute_considerations_stats(lff) - blocks = [] - for key, label, color, color_ren in CONSIDERATIONS_DISPLAY: - count_pos, pct_pos = stats[key] - count_ren, pct_ren = stats[f"{key}_renseignees"] + count_ren, pct_ren = stats["champs_renseignes"] + blocks = [ + html.Div( + className="mb-3", + children=[ + html.Div( + className="d-flex justify-content-between", + children=[ + html.Span("Champs considérations renseignés"), + html.Span( + f"{format_number(count_ren)} marchés", + className="text-muted", + ), + ], + ), + _progress_bar(pct_ren, "#6c757d"), + ], + ) + ] + + for key, label, color in CONSIDERATIONS_RENSEIGNEES: + count, pct = stats[key] blocks.append( html.Div( className="mb-3", @@ -811,22 +827,12 @@ def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div: children=[ html.Span(label), html.Span( - f"{format_number(count_pos)} marchés", + f"parmi les {format_number(count)} marchés renseignés", className="text-muted", ), ], ), - _progress_bar(pct_pos, color), - html.Div( - className="d-flex justify-content-end mt-1", - children=[ - html.Span( - f"parmi les {format_number(count_ren)} marchés renseignés", - className="text-muted", - ), - ], - ), - _progress_bar(pct_ren, color_ren), + _progress_bar(pct, color), ], ) ) diff --git a/tests/test_figures.py b/tests/test_figures.py index 040c92e..2b38eda 100644 --- a/tests/test_figures.py +++ b/tests/test_figures.py @@ -10,25 +10,25 @@ def test_compute_considerations_stats_basic(): lff = _make_lff( [ - # uid u1 : social oui (Clause), env non (Sans objet) + # u1 : social oui (Clause), env non (Sans objet) { "uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet", }, - # uid u2 : social non (Sans objet), env oui (Critère) + # u2 : social non (Sans objet), env oui (Critère) { "uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Critère environnemental", }, - # uid u3 : social oui (Marché réservé compte), env null + # u3 : social oui (Marché réservé), env null { "uid": "u3", "considerationsSociales": "Marché réservé", "considerationsEnvironnementales": None, }, - # uid u4 : aucune considération + # u4 : social autre valeur (pas "Sans objet"), env null { "uid": "u4", "considerationsSociales": "Pas de considération sociale", @@ -39,13 +39,11 @@ def test_compute_considerations_stats_basic(): stats = compute_considerations_stats(lff) - # 4 marchés au total. Social : u1, u3 -> 2/4 = 50%. Env : u2 -> 1/4 = 25%. - assert stats["sociales"] == (2, 50) - assert stats["environnementales"] == (1, 25) - # Renseignées : dénominateur = non-null ; numérateur = non-null ET != "Sans objet". - # Social : 4 non-null, 3 != "Sans objet" (u1/u3/u4) -> (4, 75%). - # Env : 3 non-null, 1 != "Sans objet" (u2) -> (3, 33%). + # champs_renseignes : basé sur sociales. 4 non-null / 4 total -> (4, 100%). + assert stats["champs_renseignes"] == (4, 100) + # Sociales renseignées : dén=4 non-null, num=3 != "Sans objet" (u1/u3/u4) -> (4, 75%). assert stats["sociales_renseignees"] == (4, 75) + # Env renseignées : dén=3 non-null (u1/u2/u4), num=1 != "Sans objet" (u2) -> (3, 33%). assert stats["environnementales_renseignees"] == (3, 33) @@ -54,7 +52,7 @@ def test_compute_considerations_stats_dedup_per_uid(): lff = _make_lff( [ - # uid u1 présent 2 fois (2 titulaires) -> compté une seule fois + # u1 présent 2 fois (2 titulaires) -> compté une seule fois { "uid": "u1", "considerationsSociales": "Clause sociale", @@ -75,11 +73,10 @@ def test_compute_considerations_stats_dedup_per_uid(): stats = compute_considerations_stats(lff) - # 2 marchés distincts. Social : u1 -> 1/2 = 50%. - assert stats["sociales"] == (1, 50) - assert stats["environnementales"] == (0, 0) - # Social : 2 non-null, 1 positif -> (2, 50%). Env : 2 non-null, 0 positif -> (2, 0%). + # 2 uid distincts. Social 2 non-null, 1 != "Sans objet" (u1) -> (2, 50%). + assert stats["champs_renseignes"] == (2, 100) assert stats["sociales_renseignees"] == (2, 50) + # Env 2 non-null, 0 != "Sans objet" -> (2, 0%). assert stats["environnementales_renseignees"] == (2, 0) @@ -95,10 +92,8 @@ def test_compute_considerations_stats_missing_column(): stats = compute_considerations_stats(lff) - # Colonne env absente -> (0, 0) sans exception. Social : 1/2 = 50%. - assert stats["sociales"] == (1, 50) - assert stats["environnementales"] == (0, 0) - # Social : 2 non-null, 1 positif -> (2, 50%). Env absente -> (0, 0). + # Colonne env absente -> (0, 0). Social : 2 non-null, 1 != "Sans objet" -> (2, 50%). + assert stats["champs_renseignes"] == (2, 100) assert stats["sociales_renseignees"] == (2, 50) assert stats["environnementales_renseignees"] == (0, 0) @@ -116,13 +111,12 @@ def test_compute_considerations_stats_empty(): stats = compute_considerations_stats(lff) - assert stats["sociales"] == (0, 0) - assert stats["environnementales"] == (0, 0) + assert stats["champs_renseignes"] == (0, 0) assert stats["sociales_renseignees"] == (0, 0) assert stats["environnementales_renseignees"] == (0, 0) -def test_get_considerations_card_content_returns_four_progress_bars(): +def test_get_considerations_card_content_returns_three_progress_bars(): import dash_bootstrap_components as dbc from dash import html @@ -158,23 +152,16 @@ def test_get_considerations_card_content_returns_four_progress_bars(): find_progress(children, found) return found - all_bars = find_progress(div, []) - inner_bars = [b for b in all_bars if getattr(b, "bar", False)] - # 4 barres internes : 2 positives + 2 renseignées - assert len(inner_bars) == 4 + inner_bars = [b for b in find_progress(div, []) if getattr(b, "bar", False)] + assert len(inner_bars) == 3 - social_pos, social_ren, env_pos, env_ren = inner_bars - # Sociales positives : u1 -> 1/2 = 50% - assert social_pos.value == 50 - assert social_pos.color == "#CC6677" - assert social_pos.style["color"] == "white" - # Sociales renseignées : 2 non-null, 1 positif (u1) -> 50% - assert social_ren.value == 50 - assert social_ren.color == "#E5B2BB" - # Environnementales positives : u2 -> 1/2 = 50% - assert env_pos.value == 50 - assert env_pos.color == "#117733" - assert env_pos.style["color"] == "white" - # Environnementales renseignées : 2 non-null (u1 "Sans objet", u2), 1 positif (u2) -> 50% - assert env_ren.value == 50 - assert env_ren.color == "#88BB99" + bar_ren, bar_social, bar_env = inner_bars + # Bar 1 : champs renseignés (2/2 = 100%, gris) + assert bar_ren.value == 100 + assert bar_ren.color == "#6c757d" + # Bar 2 : sociales parmi renseignés (u1 != "Sans objet" -> 1/2 = 50%, rose) + assert bar_social.value == 50 + assert bar_social.color == "#CC6677" + # Bar 3 : env parmi renseignés (u2 != "Sans objet" -> 1/2 = 50%, vert) + assert bar_env.value == 50 + assert bar_env.color == "#117733" From e591c5500fb380f4386393d12ad0e91d33438dc5 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 22:47:37 +0200 Subject: [PATCH 120/151] =?UTF-8?q?Petites=20am=C3=A9liorations=20du=20ren?= =?UTF-8?q?du=20#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/figures.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/figures.py b/src/figures.py index ae322ca..565001f 100644 --- a/src/figures.py +++ b/src/figures.py @@ -735,7 +735,7 @@ CONSIDERATIONS_COLUMNS = { } -def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]: +def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int, int]]: """Statistiques considérations pour l'observatoire. Clés renvoyées : @@ -746,7 +746,7 @@ def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int] names = lff.collect_schema().names() present = {key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names} - stats: dict[str, tuple[int, int]] = {"champs_renseignes": (0, 0)} + stats: dict[str, tuple[int, int, int]] = {"champs_renseignes": (0, 0)} for key in CONSIDERATIONS_COLUMNS: stats[f"{key}_renseignees"] = (0, 0) @@ -770,7 +770,7 @@ def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int] pl.col(col).is_not_null() & (pl.col(col) != "Sans objet") ).height pct_pos_ren = round(100 * count_pos_ren / count_ren) if count_ren > 0 else 0 - stats[f"{key}_renseignees"] = (count_ren, pct_pos_ren) + stats[f"{key}_renseignees"] = (count_ren, count_pos_ren, pct_pos_ren) if key == "sociales": stats["champs_renseignes"] = (count_ren, round(100 * count_ren / total)) @@ -779,8 +779,8 @@ def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int] # (clé stats, libellé, couleur) CONSIDERATIONS_RENSEIGNEES = [ - ("sociales_renseignees", "Sociales", "#CC6677"), - ("environnementales_renseignees", "Environnementales", "#117733"), + ("sociales_renseignees", "Considérations sociales", "#CC6677"), + ("environnementales_renseignees", "Considérations environnementales", "#117733"), ] @@ -817,7 +817,7 @@ def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div: ] for key, label, color in CONSIDERATIONS_RENSEIGNEES: - count, pct = stats[key] + total_count, count, pct = stats[key] blocks.append( html.Div( className="mb-3", @@ -827,7 +827,7 @@ def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div: children=[ html.Span(label), html.Span( - f"parmi les {format_number(count)} marchés renseignés", + f"dans {format_number(count)} marchés", className="text-muted", ), ], From fc22eca661db427a7cfff43bd8a6bf3ba201e635 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 23:07:23 +0200 Subject: [PATCH 121/151] Changelog 2.8.0 --- CHANGELOG.md | 18 ++++-------------- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c60783..04a3ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,8 @@ -## [Unreleased] +#### 2.8.0 -### Ajouté - -- API privée tabulaire `/api/v1/data` (filtres dynamiques, pagination, tri). -- Endpoint `/api/v1/schema` (description du dataset). -- Endpoint `/api/v1/health` (sonde monitoring). -- Documentation interactive Swagger UI à `/api/v1/swagger`. -- CLI d'administration des tokens : `python -m src.api.tokens_cli` (create, list, revoke). -- Suivi de consommation : compteurs SQLite (`api_tokens.count_total`, `last_used_at`) + événements Matomo async. - -### Configuration - -- Nouvelles variables d'environnement : `USERS_DB_PATH`, `MATOMO_URL`, `MATOMO_SITE_ID`, `MATOMO_TRACKING_ENABLED`. -- Création de 2 Custom Dimensions côté Matomo : `dimension1=token_id`, `dimension2=http_status`. +- Ajout des considérations sociales et environnementales à l'observatoire +- Les filtres textuels dans les vues tableaux ne sont plus sensibles à l'accentuation +- API privée tabulaire sur abonnement (filtres dynamiques, pagination, tri) avec documentation interactive Swagger UI à `/api/v1/swagger` (📨 si intéressé) ##### 2.7.9 (9 juin 2026) diff --git a/pyproject.toml b/pyproject.toml index 065eaa4..bbe0206 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "decp.info" description = "Interface d'exploration et d'analyse des marchés publics français." -version = "2.7.9" +version = "2.8.0" requires-python = ">= 3.10" authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }] dependencies = [ From f94701207b24d93cbeadd6e3c226161c1e922092 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 14:13:30 +0200 Subject: [PATCH 122/151] Fix non split des DISPLAYED_COLUMNS --- src/utils/table.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/utils/table.py b/src/utils/table.py index fa17880..0b04c8a 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -383,9 +383,13 @@ def get_default_hidden_columns(page): "dureeRestanteMois", ] elif page == "tableau": - displayed_columns = os.getenv("DISPLAYED_COLUMNS") + displayed_columns = [ + c.strip() for c in os.getenv("DISPLAYED_COLUMNS", "").split(",") + ] else: - displayed_columns = os.getenv("DISPLAYED_COLUMNS") + displayed_columns = [ + c.strip() for c in os.getenv("DISPLAYED_COLUMNS", "").split(",") + ] logger.warning(f"Invalid page: {page}") hidden_columns = [] From 0ed807f98ce0eda1a4ab76eebe7e31b6ce36efa2 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 15:08:47 +0200 Subject: [PATCH 123/151] =?UTF-8?q?docs:=20design=20scroll=20horizontal=20?= =?UTF-8?q?+=20en-t=C3=AAtes=20sticky=20des=20tableaux=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...ableaux-scroll-horizontal-sticky-design.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md diff --git a/docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md b/docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md new file mode 100644 index 0000000..071e7b7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md @@ -0,0 +1,117 @@ +# Défilement horizontal ergonomique des tableaux (#82) + +## Problème + +Les tableaux de données (`DataTable` Dash) sont souvent plus larges que l'écran et +**débordent vers la droite**. Aujourd'hui aucun `overflowX` n'est défini sur leur +conteneur : le tableau étire la page entière, et le seul moyen de faire défiler +horizontalement est la **barre de défilement de la fenêtre du navigateur**, tout en +bas du viewport. Cette barre : + +- est discrète et fait défiler **toute la page** (pas seulement le tableau) ; +- n'est pas comprise comme « le moyen de voir le reste du tableau ». + +De plus, les tableaux dépassent souvent du bas de l'écran : une barre placée en bas +du tableau serait invisible sans scroller. + +## Objectif + +Rendre le défilement horizontal **évident et toujours accessible**, et garder les +**en-têtes de colonnes visibles** pendant le défilement vertical, sans introduire de +zone scrollable imbriquée gênante. + +## Approche retenue (option B — sticky au niveau page) + +Le tableau **reste dans le flux de la page** (pas de conteneur à hauteur fixe, pas de +scroll imbriqué). On combine deux mécanismes : + +1. **En-têtes collants** — `position: sticky; top: 0` sur la ligne d'en-tête du + tableau. Quand l'utilisateur descend dans la page, les en-têtes se figent en haut + de la fenêtre au lieu d'être « avalés ». + +2. **Barre de défilement horizontale miroir en haut** — un petit élément placé + juste au-dessus du tableau, lui aussi `sticky` en haut, dont le défilement + horizontal est **synchronisé** avec celui du tableau. Elle est donc toujours + visible dès qu'on voit le haut du tableau, et pilote le défilement horizontal sans + devoir descendre en bas du tableau. + +La barre miroir et les en-têtes collants se figent ensemble en haut de la fenêtre : +l'utilisateur garde en permanence le repère des colonnes **et** le contrôle du +défilement horizontal. + +### Pourquoi pas l'option A (tableau « fenêtré » à hauteur fixe) + +Écartée volontairement : un conteneur à hauteur fixe avec scroll interne crée un +**scroll imbriqué** (la molette agit d'abord sur le tableau, pas sur la page), source +de confusion. L'option B garde un comportement de défilement vertical unique (celui +de la page) ; seul le défilement horizontal est « custom ». + +## Contrainte technique CSS à gérer + +Un conteneur en `overflow-x: auto` devient automatiquement un conteneur de +défilement **vertical** (règle CSS : `overflow-y: visible` recalculé en `auto` dès +que l'autre axe n'est pas `visible`), ce qui **casse** le `position: sticky; top: 0` +des en-têtes par rapport à la page. + +Conséquences pour l'implémentation : + +- Le **défilement horizontal réel** doit se faire dans un conteneur dédié en + `overflow-x: auto` ; mais ce conteneur ne peut pas, en même temps, héberger des + en-têtes sticky « page ». La barre miroir du haut résout ce conflit : c'est **elle** + qui porte le `overflow-x: auto`, séparée du tableau, et synchronisée par JS. +- Il faudra **vérifier et neutraliser au besoin l'`overflow` interne** que Dash + DataTable applique à ses propres conteneurs (`.dash-spreadsheet-container`, + `.dash-spreadsheet-inner`) pour que le sticky des en-têtes fonctionne. +- Le rendu réel de Dash DataTable doit être inspecté avant de figer le CSS : la + structure DOM exacte (où poser `sticky`, quel élément porte la largeur totale) + conditionne la solution. **À valider en testant dans le navigateur.** + +## Composants + +| Élément | Rôle | Emplacement probable | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| CSS en-têtes sticky | `position: sticky; top: 0` sur la ligne d'en-tête, z-index, fond opaque | `src/assets/css/style.css` (cible `.marches_table`) | +| Barre miroir (markup) | `
` scrollable au-dessus du tableau, avec un enfant à la largeur du tableau | composant partagé `DataTable` / wrapper dans `src/figures.py` | +| Synchronisation JS | lier scrollLeft barre ↔ tableau ; recopier la largeur du tableau dans la barre ; recalcul au resize / changement de données | nouveau fichier `src/assets/js/*.js` (assets Dash, chargé automatiquement) | +| CSS barre miroir | hauteur, sticky `top: 0`, masquage si pas de débordement | `src/assets/css/style.css` | + +## Portée + +Les **quatre pages** utilisant `className="marches_table"` : +`/tableau`, `/acheteur`, `/titulaire`, `/observatoire`. La solution passe par le +composant `DataTable` partagé et la classe CSS `marches_table`, donc l'effort est +quasi identique pour une ou quatre pages. + +## Flux de données / interactions + +1. Au rendu (et à chaque changement de données / largeur de fenêtre), le JS mesure la + largeur totale du tableau et la reporte dans l'élément interne de la barre miroir → + la barre miroir affiche une glissière proportionnelle. +2. Événement `scroll` sur la barre miroir → on applique `scrollLeft` au conteneur du + tableau ; et inversement (scroll du tableau → barre miroir), avec garde anti-boucle. +3. Si le tableau ne déborde pas, la barre miroir est masquée. + +## Cas limites + +- **Pas de débordement** : barre miroir masquée, en-têtes sticky inoffensifs. +- **Pagination / re-render** (pages en `page_action="custom"`) : la largeur peut + changer → la synchro doit se recalculer après mise à jour des données. +- **Resize de la fenêtre** : recalcul de la largeur miroir. +- **Plusieurs tableaux sur une page** (`/observatoire`, `/titulaire`, `/acheteur` ont + plusieurs `marches_table`) : le JS doit gérer chaque tableau indépendamment. +- **Persistance / tri / filtre** : ne doit pas casser la synchro (réattacher les + écouteurs si le DOM est recréé). + +## Tests / validation + +- Vérification **manuelle dans le navigateur** (point critique vu l'incertitude sur le + DOM de DataTable) : débordement horizontal sur `/tableau`, sticky des en-têtes en + scrollant, synchro des deux barres, comportement sur les pages à tableaux multiples. +- S'assurer que les tests Selenium existants ne régressent pas + (`pytest tests/test_main.py`). + +## Hors périmètre (YAGNI) + +- Colonnes figées (1re colonne sticky horizontalement). +- Réduction du nombre de colonnes par défaut / refonte du sélecteur de colonnes. +- `overscroll-behavior` et zones scrollables imbriquées (option A écartée). From 68bd398759e08a8e04b9cb13f60b11761be4d165 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 15:14:03 +0200 Subject: [PATCH 124/151] =?UTF-8?q?docs:=20plan=20d'impl=C3=A9mentation=20?= =?UTF-8?q?scroll=20horizontal=20tableaux=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...06-23-tableaux-scroll-horizontal-sticky.md | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-tableaux-scroll-horizontal-sticky.md diff --git a/docs/superpowers/plans/2026-06-23-tableaux-scroll-horizontal-sticky.md b/docs/superpowers/plans/2026-06-23-tableaux-scroll-horizontal-sticky.md new file mode 100644 index 0000000..8bddc51 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-tableaux-scroll-horizontal-sticky.md @@ -0,0 +1,350 @@ +# Défilement horizontal ergonomique des tableaux (#82) — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rendre le défilement horizontal des tableaux toujours accessible (barre miroir en haut, synchronisée) et garder les en-têtes de colonnes visibles pendant le défilement vertical de la page. + +**Architecture:** Option B (sticky au niveau page). Le tableau reste dans le flux de la page. CSS rend les en-têtes `position: sticky; top: 0`. Une barre de défilement « miroir » est injectée par JS au-dessus de chaque tableau, sticky en haut, et synchronisée avec le conteneur scrollable du tableau. Aucun scroll vertical imbriqué. + +**Tech Stack:** Dash 3.4 `dash_table.DataTable`, CSS (`src/assets/css/style.css`), JS vanilla auto-chargé depuis `src/assets/` (pattern existant : MutationObserver, cf. `dash_clientside.js`), tests Selenium (`dash[testing]` / `DashComposite`). + +## Global Constraints + +- Importer les modules de l'app avec le préfixe `src.` (ex. `src.figures`), jamais `figures`. +- UI en français. +- Cibler la classe partagée `marches_table` (présente sur les 4 pages : `/tableau`, `/acheteur`, `/titulaire`, `/observatoire`) — pas de duplication par page. +- Ne pas modifier la hauteur du tableau ni introduire de conteneur à hauteur fixe (option A explicitement écartée). +- Les commits référencent `#82`. +- Le pre-commit hook lance `prettier` (CSS/JS/MD) et `ruff` (Python) et **modifie les fichiers** : après un échec dû au reformatage, refaire `git add` puis recommiter. +- Terminer chaque message de commit par : `Co-Authored-By: Claude Opus 4.8 `. + +--- + +## File Structure + +| Fichier | Responsabilité | Action | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | +| `src/assets/css/style.css` | En-têtes sticky + neutralisation overflow interne Dash + styles de la barre miroir | Modifier | +| `src/assets/table_hscroll.js` | Injecter la barre miroir au-dessus de chaque `.marches_table`, synchroniser le scroll, masquer si pas de débordement, recalculer au resize / re-render | Créer | +| `tests/test_tableau_hscroll.py` | Test Selenium : barre miroir présente + en-tête sticky sur `/tableau` | Créer | +| `docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md` | Consigner le verdict du spike (Task 1) | Modifier | + +**Décision DOM clé :** la barre miroir est **injectée par JS** (et non ajoutée dans le markup Python), ce qui évite de toucher au markup des 4 pages et fonctionne quel que soit le wrapper. Le conteneur scrollable du tableau est l'élément Dash `.dash-spreadsheet-container` (ou son parent direct), confirmé au spike. + +--- + +## Task 1 : Spike — vérifier le DOM réel et la technique sticky + +**But :** lever l'inconnue principale (structure DOM de DataTable + compatibilité `position: sticky` des en-têtes avec un conteneur `overflow-x`). Produit un verdict écrit qui pilote les tâches suivantes. + +**Files:** + +- Modify: `docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md` (section « Verdict du spike ») + +- [ ] **Step 1 : Lancer l'app** + +```bash +source .venv/bin/activate +python run.py +``` + +Ouvrir `http://127.0.0.1:8050/tableau` dans le navigateur. + +- [ ] **Step 2 : Inspecter le DOM du tableau** + +Dans les DevTools, sur le tableau, relever et noter : + +- l'élément qui porte la **largeur totale** du tableau (table `.cell-table`) et sa largeur (`scrollWidth`) vs. celle de son parent (`clientWidth`) → confirme le débordement ; +- l'élément qui (le cas échéant) porte déjà un `overflow`/`overflow-x` (regarder `.dash-spreadsheet-container`, `.dash-spreadsheet-inner`) via l'onglet _Computed_ ; +- le sélecteur exact de la ligne d'en-tête (attendu : `th.dash-header` dans un `tr`). + +- [ ] **Step 3 : Tester l'hypothèse sticky en live** + +Dans la console DevTools, appliquer à chaud : + +```js +document + .querySelectorAll( + ".marches_table .dash-spreadsheet-container, .marches_table .dash-spreadsheet-inner" + ) + .forEach((e) => (e.style.overflow = "visible")); +document.querySelectorAll(".marches_table th.dash-header").forEach((e) => { + e.style.position = "sticky"; + e.style.top = "0"; + e.style.zIndex = "10"; + e.style.background = "#fff"; +}); +``` + +Scroller verticalement la page → **les en-têtes restent-ils collés en haut ?** + +- [ ] **Step 4 : Consigner le verdict** + +Ajouter une section « Verdict du spike » à la spec, répondant à : + +- sélecteur exact de l'en-tête et du conteneur scrollable ; +- l'astuce `overflow: visible` sur les conteneurs Dash suffit-elle à faire fonctionner le sticky page ? (attendu : oui) ; +- si **non** : noter que les en-têtes sticky devront être pilotés en JS (repositionnement au scroll) — le reste du plan reste valable, seul l'implémentation du sticky de la Task 2 change. + +- [ ] **Step 5 : Commit** + +```bash +git add docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md +git commit -m "docs: verdict spike DOM tableaux #82 + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 2 : En-têtes collants (CSS) + +**But :** les en-têtes de colonnes restent visibles en haut de la fenêtre pendant le défilement vertical de la page. + +**Files:** + +- Modify: `src/assets/css/style.css` (ajouter après le bloc `.marches_table.stuck`, vers la ligne 360) + +**Interfaces:** + +- Consumes: sélecteurs confirmés au spike (Task 1) : `.marches_table th.dash-header`, conteneurs `.dash-spreadsheet-container` / `.dash-spreadsheet-inner`. +- Produces: classe `marches_table` dont les conteneurs Dash internes sont en `overflow: visible` et dont les en-têtes sont sticky — la Task 3 (JS) s'appuie dessus pour poser le conteneur scrollable et la barre miroir. + +- [ ] **Step 1 : Écrire le CSS des en-têtes sticky** + +Dans `src/assets/css/style.css`, ajouter : + +```css +/* ===== Tableaux : en-têtes collants + scroll horizontal (#82) ===== */ + +/* Neutraliser l'overflow interne de Dash pour que le sticky se cale sur la page */ +.marches_table .dash-spreadsheet-container, +.marches_table .dash-spreadsheet-inner { + overflow: visible !important; +} + +/* En-têtes de colonnes collants en haut de la fenêtre */ +.marches_table th.dash-header { + position: sticky; + top: 0; + z-index: 10; + background-color: #fff; +} +``` + +> Si le verdict du spike indique que le sticky CSS ne tient pas, remplacer ce bloc par le repositionnement JS décrit dans la spec et le porter en Task 3 ; documenter le choix dans le commit. + +- [ ] **Step 2 : Vérifier dans le navigateur** + +App lancée (`python run.py`), sur `/tableau` : scroller verticalement → l'en-tête reste figé en haut, sur fond opaque, au-dessus des lignes. Vérifier aussi `/acheteur` et `/observatoire`. + +- [ ] **Step 3 : Commit** + +```bash +git add src/assets/css/style.css +git commit -m "feat(tableaux): en-têtes de colonnes collants #82 + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 3 : Barre de défilement miroir (JS + CSS) + +**But :** une barre de défilement horizontale toujours visible en haut du tableau, synchronisée avec le défilement horizontal du tableau, masquée si le tableau ne déborde pas. + +**Files:** + +- Create: `src/assets/table_hscroll.js` +- Modify: `src/assets/css/style.css` (styles de `.dt-hscroll`) + +**Interfaces:** + +- Consumes: les `.marches_table` rendues par Dash ; le conteneur scrollable interne (`.dash-spreadsheet-container`, confirmé au spike) dont on lit `scrollWidth`/`clientWidth` et qu'on pilote via `scrollLeft`. +- Produces: pour chaque `.marches_table`, un nœud `
` inséré en première position, contenant `
` dont la largeur = largeur totale du tableau. + +- [ ] **Step 1 : Écrire le CSS de la barre miroir** + +Dans `src/assets/css/style.css`, à la suite du bloc de la Task 2 : + +```css +/* Barre de défilement horizontale miroir, collée en haut */ +.marches_table .dt-hscroll { + position: sticky; + top: 0; + z-index: 11; /* au-dessus des en-têtes sticky */ + overflow-x: auto; + overflow-y: hidden; + height: 14px; +} + +.marches_table .dt-hscroll-inner { + height: 1px; +} + +.marches_table .dt-hscroll.is-hidden { + display: none; +} +``` + +- [ ] **Step 2 : Écrire le JS de synchronisation** + +Créer `src/assets/table_hscroll.js` : + +```js +// Barre de défilement horizontale miroir pour les tableaux (.marches_table) — #82 +(function () { + "use strict"; + + // Renvoie le conteneur réellement scrollable horizontalement du tableau. + function getScrollEl(wrapper) { + return wrapper.querySelector(".dash-spreadsheet-container") || wrapper; + } + + function setup(wrapper) { + if (wrapper.dataset.hscrollReady === "1") return; + const scrollEl = getScrollEl(wrapper); + if (!scrollEl) return; + + const bar = document.createElement("div"); + bar.className = "dt-hscroll is-hidden"; + const inner = document.createElement("div"); + inner.className = "dt-hscroll-inner"; + bar.appendChild(inner); + wrapper.insertBefore(bar, wrapper.firstChild); + + let syncing = false; + const onBar = () => { + if (syncing) return; + syncing = true; + scrollEl.scrollLeft = bar.scrollLeft; + syncing = false; + }; + const onTable = () => { + if (syncing) return; + syncing = true; + bar.scrollLeft = scrollEl.scrollLeft; + syncing = false; + }; + bar.addEventListener("scroll", onBar); + scrollEl.addEventListener("scroll", onTable); + + const refresh = () => { + const total = scrollEl.scrollWidth; + const visible = scrollEl.clientWidth; + inner.style.width = total + "px"; + bar.classList.toggle("is-hidden", total <= visible + 1); + bar.scrollLeft = scrollEl.scrollLeft; + }; + + // Recalcule quand le tableau change (pagination, tri, filtre, données). + const obs = new MutationObserver(() => refresh()); + obs.observe(scrollEl, { childList: true, subtree: true, attributes: true }); + window.addEventListener("resize", refresh); + + wrapper.dataset.hscrollReady = "1"; + refresh(); + } + + function scan() { + document.querySelectorAll(".marches_table").forEach(setup); + } + + // Les tableaux apparaissent après le rendu Dash : observer le body. + const rootObs = new MutationObserver(() => scan()); + rootObs.observe(document.body, { childList: true, subtree: true }); + scan(); +})(); +``` + +- [ ] **Step 3 : Vérifier dans le navigateur** + +App lancée, sur `/tableau` : une barre fine apparaît en haut du tableau ; la faire glisser déplace le tableau horizontalement, et inversement. Réduire la fenêtre / élargir → la barre apparaît/disparaît selon le débordement. Changer de page de pagination → la barre se recalcule. Vérifier que `/acheteur`, `/titulaire`, `/observatoire` (tableaux multiples) fonctionnent chacun indépendamment. + +- [ ] **Step 4 : Commit** + +```bash +git add src/assets/table_hscroll.js src/assets/css/style.css +git commit -m "feat(tableaux): barre de défilement horizontale miroir synchronisée #82 + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 4 : Test Selenium de non-régression + +**But :** garantir que la barre miroir est rendue et que les en-têtes sont sticky, et que rien ne casse le chargement de `/tableau`. + +**Files:** + +- Create: `tests/test_tableau_hscroll.py` + +**Interfaces:** + +- Consumes: éléments DOM produits par Task 2 (`th.dash-header` sticky) et Task 3 (`.marches_table .dt-hscroll`). + +- [ ] **Step 1 : Écrire le test** + +Créer `tests/test_tableau_hscroll.py` (s'aligner sur le style des tests existants dans `tests/`, notamment l'usage de `dash_duo`/`DashComposite` et l'import de l'app) : + +```python +from selenium.webdriver.common.by import By + + +def test_tableau_hscroll_bar_present(dash_duo, start_app): + """La barre miroir est injectée et l'en-tête est collant sur /tableau.""" + dash_duo.wait_for_element(".marches_table", timeout=20) + # Barre miroir injectée par table_hscroll.js + dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10) + + header = dash_duo.find_element(".marches_table th.dash-header") + position = header.value_of_css_property("position") + assert position == "sticky" +``` + +> Adapter les fixtures (`dash_duo`, `start_app`, navigation initiale vers `/tableau`) à ce qui existe déjà dans `tests/` : reprendre le mécanisme d'amorçage utilisé par les autres tests (par ex. `tests/test_main.py`) plutôt que d'en inventer un. + +- [ ] **Step 2 : Lancer le test et vérifier qu'il échoue si on retire le JS** (sanity) + +```bash +rtk pytest tests/test_tableau_hscroll.py -v +``` + +Expected: PASS avec le JS/CSS en place. + +- [ ] **Step 3 : Lancer la suite Selenium impactée** + +```bash +rtk pytest tests/test_main.py -v +``` + +Expected: pas de régression (mêmes résultats qu'avant la branche). + +- [ ] **Step 4 : Commit** + +```bash +git add tests/test_tableau_hscroll.py +git commit -m "test(tableaux): barre miroir et en-tête sticky sur /tableau #82 + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Self-Review + +**Couverture spec :** + +- En-têtes sticky → Task 2. ✓ +- Barre miroir synchronisée en haut → Task 3. ✓ +- Masquage si pas de débordement → Task 3 (`is-hidden`). ✓ +- Recalcul pagination/tri/filtre/resize → Task 3 (MutationObserver + resize). ✓ +- Plusieurs tableaux par page → Task 3 (`querySelectorAll` + `dataset.hscrollReady`). ✓ +- Contrainte overflow CSS → Task 1 (spike) + Task 2 (`overflow: visible`). ✓ +- Portée 4 pages via `marches_table` → ciblage CSS/JS global. ✓ +- Validation navigateur + non-régression Selenium → Task 1/2/3 (manuel) + Task 4. ✓ + +**Placeholders :** les renvois « adapter aux fixtures existantes » de la Task 4 pointent vers `tests/test_main.py` comme référence concrète (les fixtures Selenium du projet ne sont pas réinventées ici à dessein) ; aucun TODO/TBD ailleurs. + +**Cohérence des noms :** classes `dt-hscroll` / `dt-hscroll-inner` / `is-hidden` et flag `dataset.hscrollReady` utilisés de façon identique entre CSS (Task 3 step 1) et JS (Task 3 step 2). `marches_table` et `th.dash-header` cohérents entre Task 2 et Task 4. From 3e305845dd20878e2bf15228c9327d5c10e32140 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 15:28:42 +0200 Subject: [PATCH 125/151] =?UTF-8?q?Fix=20r=C3=A9duction=20du=20nombre=20de?= =?UTF-8?q?=20colonne,=20ajoute=20de=20lla=20colonne=20March=C3=A9=20#84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .template.env | 2 +- src/assets/css/style.css | 9 +++++++++ src/utils/data.py | 8 ++++++++ src/utils/table.py | 12 +++++++----- tests/test_table.py | 28 ++++++++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 54 insertions(+), 7 deletions(-) diff --git a/.template.env b/.template.env index 6b9744a..fe1ac51 100644 --- a/.template.env +++ b/.template.env @@ -12,7 +12,7 @@ DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-be DATA_SCHEMA_CACHE=./schema.cache.json # Colonnes masquées par défaut -DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom, titulaire_id, dateNotification, dureeMois, acheteur_departement_code, sourceDataset" +DISPLAYED_COLUMNS="acheteur_nom, montant, objet, titulaire_nom, dateNotification, dureeMois, acheteur_departement_code" # Formulaire de contact SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP diff --git a/src/assets/css/style.css b/src/assets/css/style.css index ebe7ecb..06bb670 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -363,6 +363,15 @@ table.cell-table th { background-color: rgb(255 240 240 / 40%); } +/* Colonne "Marché" : lien loupe centré et sans soulignement */ +table.cell-table td[data-dash-column="marche"] .dash-cell-value { + text-align: center !important; +} + +td[data-dash-column="marche"] a { + text-decoration: none; +} + /* Column Visibility Menu */ .column-actions { margin-right: 8px; diff --git a/src/utils/data.py b/src/utils/data.py index 58a9637..ab7cb63 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -158,3 +158,11 @@ DF_TITULAIRES = build_org_frame("titulaire") DEPARTEMENTS = get_departements() DEPARTEMENTS_GEOJSON = get_departements_geojson() DATA_SCHEMA = get_data_schema() +# Colonne virtuelle (dérivée de uid) : lien loupe vers la fiche du marché. +# Absente du schéma DuckDB, créée à l'affichage dans postprocess_page(). +DATA_SCHEMA["marche"] = { + "name": "marche", + "type": "string", + "title": "Marché", + "description": "Lien vers la fiche détaillée du marché.", +} diff --git a/src/utils/table.py b/src/utils/table.py index 0b04c8a..52b07e4 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -359,11 +359,8 @@ def setup_table_columns( def get_default_hidden_columns(page): if page == "acheteur": displayed_columns = [ - "uid", "objet", "dateNotification", - "titulaire_id", - "titulaire_typeIdentifiant", "titulaire_nom", "titulaire_distance", "montant", @@ -372,10 +369,8 @@ def get_default_hidden_columns(page): ] elif page == "titulaire": displayed_columns = [ - "uid", "objet", "dateNotification", - "acheteur_id", "acheteur_nom", "titulaire_distance", "montant", @@ -408,6 +403,13 @@ def postprocess_page(dff: pl.DataFrame) -> pl.DataFrame: À appeler après la pagination. """ dff = dff.with_columns(pl.all().cast(pl.String).fill_null("")) + if "uid" in dff.columns: + dff = dff.with_columns( + ( + '🔍' + ).alias("marche") + ) + dff = dff.select(["marche"] + [c for c in dff.columns if c != "marche"]) dff = add_links(dff) if "sourceFile" in dff.columns: dff = add_resource_link(dff) diff --git a/tests/test_table.py b/tests/test_table.py index 0df5e50..a0ab08e 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -313,3 +313,31 @@ def test_fetch_page_sql_post_processes_links(flask_app): ) if page.height > 0: assert " Date: Tue, 23 Jun 2026 15:44:11 +0200 Subject: [PATCH 126/151] docs: verdict spike DOM tableaux #82 Co-Authored-By: Claude Opus 4.8 --- ...ableaux-scroll-horizontal-sticky-design.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md b/docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md index 071e7b7..eba15d2 100644 --- a/docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md +++ b/docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md @@ -115,3 +115,31 @@ quasi identique pour une ou quatre pages. - Colonnes figées (1re colonne sticky horizontalement). - Réduction du nombre de colonnes par défaut / refonte du sélecteur de colonnes. - `overscroll-behavior` et zones scrollables imbriquées (option A écartée). + +## Verdict du spike + +Le spike (Steps 1–3 exécutés par l'utilisateur en DevTools) doit valider les points suivants. En cas de déviation, le reste du plan reste applicable ; seule l'implémentation du sticky (Task 2) ajustera sa stratégie. + +### Éléments attendus du DOM + +- **Conteneur scrollable** : `.dash-spreadsheet-container` (enfant direct de `.marches_table`) +- **Conteneur interne** : `.dash-spreadsheet-inner` (enfant de `.dash-spreadsheet-container`) — peut aussi porter un `overflow` interne +- **En-têtes** : sélecteur exact `th.dash-header` (dans un `tr` au sein du tableau) +- **Table complète** : `.cell-table` avec ses dimensions (`scrollWidth` >> `clientWidth` du parent → débordement confirmé) + +### Hypothèse sticky + +L'astuce CSS consiste à : + +1. Neutraliser l'`overflow` sur `.dash-spreadsheet-container` et `.dash-spreadsheet-inner` en les ramenant à `overflow: visible` (ou en supprimant le style si possible) +2. Appliquer `position: sticky; top: 0; z-index: 10; background: #fff` aux en-têtes `th.dash-header` + +**Verdict attendu :** ✅ Oui — les en-têtes restent collés au haut de la fenêtre quand on scroll verticalement la page, sans recours à du JS supplémentaire (hormis la synchro scrollLeft pour le miroir). + +**Si verdict = ❌ Non :** les en-têtes seront pilotés entièrement en JS (repositionnement au scroll), avec synchronisation du scroll vertical. Le reste du plan (barre miroir, synchro horizontale) reste valable. + +### Références (à noter lors du spike) + +- `scrollWidth` et `clientWidth` de la table vs. ses parents +- Styles `overflow` en _Computed_ sur `.dash-spreadsheet-container`, `.dash-spreadsheet-inner` et `.cell-table` +- Résultat du test d'hypothèse JS (sticky page fonctionne-t-il ?) From 83d38f848941faa2a447c67df7897203461a4f13 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 15:48:37 +0200 Subject: [PATCH 127/151] Correction du mode d'emploi sur l'accentuation --- src/pages/tableau.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index c6ef4f4..1a272ff 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -174,11 +174,11 @@ layout = [ Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`. - - Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché, n'est pas sensible à la casse (majuscules/minuscules) et est sensbible à l'accentuation. + - Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché, n'est sensible ni à la casse (majuscules/minuscules), ni à l'accentuation. - `rennes` => le texte contient "rennes" - `metro* *pole` => le texte contient un mot qui commence par "metro" et un mot qui finit par "pole" - `metropole rennes` => le texte contient les mots "metropole" et "rennes", n'importe où dans le texte - - `metropole+rennes` => le texte contient "metropole rennes", collé et dans cet ordre + - `métropole+rennes` => le texte contient "metropole rennes" ou "métropole rennes", collé et dans cet ordre - `metropole+rennes travaux distri*` => le texte contient "metropole rennes", "travaux" et un mot qui commence par "distri" - Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances` - Champs numériques (Durée en mois, Montant, ...) : vous pouvez... From 4be82ac76b0f6fb4834a0fedab3f5d52946432dc Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 15:57:35 +0200 Subject: [PATCH 128/151] =?UTF-8?q?feat(tableaux):=20en-t=C3=AAtes=20de=20?= =?UTF-8?q?colonnes=20collants=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index ebe7ecb..87cd9df 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -359,6 +359,22 @@ table.cell-table th { right: 200px; } +/* ===== Tableaux : en-têtes collants + scroll horizontal (#82) ===== */ + +/* Neutraliser l'overflow interne de Dash pour que le sticky se cale sur la page */ +.marches_table .dash-spreadsheet-container, +.marches_table .dash-spreadsheet-inner { + overflow: visible !important; +} + +/* En-têtes de colonnes collants en haut de la fenêtre */ +.marches_table th.dash-header { + position: sticky; + top: 0; + z-index: 10; + background-color: #fff; +} + .marches_table .cell-table tr:nth-child(even) td { background-color: rgb(255 240 240 / 40%); } From cb1685d0552a29a7203f6c76980e4f92e01762d8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 16:00:45 +0200 Subject: [PATCH 129/151] =?UTF-8?q?feat(tableaux):=20barre=20de=20d=C3=A9f?= =?UTF-8?q?ilement=20horizontale=20miroir=20synchronis=C3=A9e=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 18 +++++++++++ src/assets/table_hscroll.js | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/assets/table_hscroll.js diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 87cd9df..c8a188c 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -379,6 +379,24 @@ table.cell-table th { background-color: rgb(255 240 240 / 40%); } +/* Barre de défilement horizontale miroir, collée en haut */ +.marches_table .dt-hscroll { + position: sticky; + top: 0; + z-index: 11; /* au-dessus des en-têtes sticky */ + overflow-x: auto; + overflow-y: hidden; + height: 14px; +} + +.marches_table .dt-hscroll-inner { + height: 1px; +} + +.marches_table .dt-hscroll.is-hidden { + display: none; +} + /* Column Visibility Menu */ .column-actions { margin-right: 8px; diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js new file mode 100644 index 0000000..626ae27 --- /dev/null +++ b/src/assets/table_hscroll.js @@ -0,0 +1,63 @@ +// Barre de défilement horizontale miroir pour les tableaux (.marches_table) — #82 +(function () { + "use strict"; + + // Renvoie le conteneur réellement scrollable horizontalement du tableau. + function getScrollEl(wrapper) { + return wrapper.querySelector(".dash-spreadsheet-container") || wrapper; + } + + function setup(wrapper) { + if (wrapper.dataset.hscrollReady === "1") return; + const scrollEl = getScrollEl(wrapper); + if (!scrollEl) return; + + const bar = document.createElement("div"); + bar.className = "dt-hscroll is-hidden"; + const inner = document.createElement("div"); + inner.className = "dt-hscroll-inner"; + bar.appendChild(inner); + wrapper.insertBefore(bar, wrapper.firstChild); + + let syncing = false; + const onBar = () => { + if (syncing) return; + syncing = true; + scrollEl.scrollLeft = bar.scrollLeft; + syncing = false; + }; + const onTable = () => { + if (syncing) return; + syncing = true; + bar.scrollLeft = scrollEl.scrollLeft; + syncing = false; + }; + bar.addEventListener("scroll", onBar); + scrollEl.addEventListener("scroll", onTable); + + const refresh = () => { + const total = scrollEl.scrollWidth; + const visible = scrollEl.clientWidth; + inner.style.width = total + "px"; + bar.classList.toggle("is-hidden", total <= visible + 1); + bar.scrollLeft = scrollEl.scrollLeft; + }; + + // Recalcule quand le tableau change (pagination, tri, filtre, données). + const obs = new MutationObserver(() => refresh()); + obs.observe(scrollEl, { childList: true, subtree: true, attributes: true }); + window.addEventListener("resize", refresh); + + wrapper.dataset.hscrollReady = "1"; + refresh(); + } + + function scan() { + document.querySelectorAll(".marches_table").forEach(setup); + } + + // Les tableaux apparaissent après le rendu Dash : observer le body. + const rootObs = new MutationObserver(() => scan()); + rootObs.observe(document.body, { childList: true, subtree: true }); + scan(); +})(); From 2413ffb4f01d277ebd0d881dfc1f229f23b66d8d Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 16:05:49 +0200 Subject: [PATCH 130/151] =?UTF-8?q?test(tableaux):=20barre=20miroir=20et?= =?UTF-8?q?=20en-t=C3=AAte=20sticky=20sur=20/tableau=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- tests/test_tableau_hscroll.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/test_tableau_hscroll.py diff --git a/tests/test_tableau_hscroll.py b/tests/test_tableau_hscroll.py new file mode 100644 index 0000000..6699c89 --- /dev/null +++ b/tests/test_tableau_hscroll.py @@ -0,0 +1,16 @@ +from dash.testing.composite import DashComposite + + +def test_tableau_hscroll_bar_present(dash_duo: DashComposite): + """La barre miroir est injectée et l'en-tête est collant sur /tableau.""" + from src.app import app + + dash_duo.start_server(app) + dash_duo.wait_for_page(f"{dash_duo.server_url}/tableau") + dash_duo.wait_for_element(".marches_table", timeout=20) + # Barre miroir injectée par table_hscroll.js + dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10) + + header = dash_duo.find_element(".marches_table th.dash-header") + position = header.value_of_css_property("position") + assert position == "sticky" From ce4b06fe3df6ee0f9dadc66091b8a1849b6e22ec Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 16:14:20 +0200 Subject: [PATCH 131/151] refactor(tableaux): commentaires et nettoyage table_hscroll.js #82 Co-Authored-By: Claude Opus 4.8 --- src/assets/table_hscroll.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 626ae27..8cb8085 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -10,7 +10,6 @@ function setup(wrapper) { if (wrapper.dataset.hscrollReady === "1") return; const scrollEl = getScrollEl(wrapper); - if (!scrollEl) return; const bar = document.createElement("div"); bar.className = "dt-hscroll is-hidden"; @@ -19,6 +18,8 @@ bar.appendChild(inner); wrapper.insertBefore(bar, wrapper.firstChild); + // syncing évite les boucles dans la même pile d'exécution ; l'affectation de scrollLeft + // déclenche l'événement scroll de façon synchrone dans Chromium. let syncing = false; const onBar = () => { if (syncing) return; @@ -39,6 +40,7 @@ const total = scrollEl.scrollWidth; const visible = scrollEl.clientWidth; inner.style.width = total + "px"; + // +1 absorbe les erreurs d'arrondi sous-pixel bar.classList.toggle("is-hidden", total <= visible + 1); bar.scrollLeft = scrollEl.scrollLeft; }; @@ -46,6 +48,7 @@ // Recalcule quand le tableau change (pagination, tri, filtre, données). const obs = new MutationObserver(() => refresh()); obs.observe(scrollEl, { childList: true, subtree: true, attributes: true }); + // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. window.addEventListener("resize", refresh); wrapper.dataset.hscrollReady = "1"; From 10ed9c4c4bc480461fb4c5746f368b11bb728e5b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 16:21:20 +0200 Subject: [PATCH 132/151] =?UTF-8?q?Spec=20:=20am=C3=A9lioration=20du=20sty?= =?UTF-8?q?le=20des=20exports=20Excel=20#83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../2026-06-23-excel-export-styling-design.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-23-excel-export-styling-design.md diff --git a/docs/superpowers/specs/2026-06-23-excel-export-styling-design.md b/docs/superpowers/specs/2026-06-23-excel-export-styling-design.md new file mode 100644 index 0000000..7aba745 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-excel-export-styling-design.md @@ -0,0 +1,100 @@ +# Design : amélioration du style des exports Excel (#83) + +**Date :** 2026-06-23 +**Issue :** #83 + +## Contexte + +Les 6 fonctions d'export Excel du projet produisent des fichiers basiques : toutes les colonnes ont la même largeur par défaut et les en-têtes ne sont pas mis en valeur. L'objectif est d'améliorer la lisibilité en imitant les largeurs de colonnes du `DataTable` commun et en stylisant les en-têtes. + +## Périmètre + +Toutes les fonctions d'export Excel : + +| Fichier | Fonction | Page | +| --------------------------- | ---------------------------------- | --------------- | +| `src/pages/tableau.py` | `download_data` | `/tableau` | +| `src/pages/acheteur.py` | `download_acheteur_data` | `/acheteur` | +| `src/pages/acheteur.py` | `download_filtered_acheteur_data` | `/acheteur` | +| `src/pages/titulaire.py` | `download_titulaire_data` | `/titulaire` | +| `src/pages/titulaire.py` | `download_filtered_titulaire_data` | `/titulaire` | +| `src/pages/observatoire.py` | `download_observatoire` | `/observatoire` | + +## Solution retenue : wrapper `write_styled_excel` (approche C) + +Le besoin de `text_wrap` impose de créer le workbook manuellement (`xlsxwriter.Workbook` avec `default_format_properties`). Répéter ce boilerplate 6 fois est peu maintenable, donc on centralise dans une fonction utilitaire dans `src/utils/table.py`. + +## Détail du design + +### Constantes et wrapper — `src/utils/table.py` + +```python +import xlsxwriter + +_EXCEL_MIN_COLUMN_WIDTH = 132 # ≈ 3.5 cm à 96 DPI +_EXCEL_HEADER_FORMAT = { + "bold": True, + "bg_color": "#b33821", # couleur primaire de l'app + "font_color": "white", +} +_EXCEL_COLUMN_WIDTHS = { # tirés des minWidth du DataTable commun (src/figures.py:269) + "objet": 350, + "acheteur_nom": 250, + "titulaire_nom": 250, + "acheteur_id": 160, +} + +def write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None: + col_widths = { + col: max(_EXCEL_MIN_COLUMN_WIDTH, _EXCEL_COLUMN_WIDTHS.get(col, 0)) + for col in df.columns + } + wb = xlsxwriter.Workbook(buffer, {"default_format_properties": {"text_wrap": True}}) + ws = wb.add_worksheet(worksheet) + df.write_excel( + workbook=wb, + worksheet=ws, + header_format=_EXCEL_HEADER_FORMAT, + column_widths=col_widths, + ) + wb.close() +``` + +**Comportement :** + +- `text_wrap=True` via `default_format_properties` s'applique à toutes les cellules de données. +- Chaque colonne reçoit au minimum 132 px (≈ 3.5 cm) ; les colonnes avec largeur explicite utilisent leur valeur si elle est supérieure. +- En-têtes : fond rouge (`#b33821`), texte blanc, gras. +- Le wrapper accepte un `pl.DataFrame` ; les callbacks qui travaillent avec une `LazyFrame` appellent `.collect()` (éventuellement `engine="streaming"`) avant d'appeler le wrapper. + +### Mise à jour des 6 callbacks + +Chaque bloc `def to_bytes(buffer):` est remplacé par un appel au wrapper. + +**Exemples :** + +```python +# tableau.py — download_data +def to_bytes(buffer): + write_styled_excel(lff.collect(engine="streaming"), buffer) + +# acheteur.py — download_acheteur_data (worksheet dynamique selon l'année) +def to_bytes(buffer): + write_styled_excel( + df_to_download, buffer, + worksheet="DECP" if annee in ["Toutes les années", None] else annee, + ) + +# acheteur.py — download_filtered_acheteur_data +def to_bytes(buffer): + write_styled_excel(lff.collect(engine="streaming"), buffer) +``` + +Titulaire et observatoire : même pattern. + +## Décisions clés + +- **`autofit=False`** (pas d'autofit Polars) : les largeurs sont entièrement contrôlées par `col_widths`. +- **`text_wrap` via workbook** : seule façon d'appliquer le wrapping à toutes les cellules via `write_excel` (les paramètres `column_formats`/`dtype_formats` de Polars n'exposent pas les propriétés xlsxwriter de format cellule). +- **Constantes privées** (`_EXCEL_*`) : non exportées, consommées uniquement par `write_styled_excel`. +- **Worksheet dynamique** : `download_acheteur_data` et `download_titulaire_data` utilisent l'année comme nom de feuille quand elle est définie — conservé via le paramètre `worksheet`. From 96c0b0dd356bcf8a7b5a227f93dc85f3889d7501 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 16:52:22 +0200 Subject: [PATCH 133/151] fix(tableaux): scroll horizontal via wrapper overflow-x auto #82 Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 8 ++++++++ src/assets/table_hscroll.js | 38 ++++++++++++++++++++++--------------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index c8a188c..a98f098 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -397,6 +397,14 @@ table.cell-table th { display: none; } +/* Conteneur scrollable horizontal — overflow-y:clip ne crée pas de scroll container vertical */ +/* donc position:sticky sur th.dash-header reste calé sur la page */ +.marches_table .dt-scroll-wrapper { + overflow-x: auto; + overflow-y: clip; + width: 100%; +} + /* Column Visibility Menu */ .column-actions { margin-right: 8px; diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 8cb8085..829db34 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -1,22 +1,26 @@ -// Barre de défilement horizontale miroir pour les tableaux (.marches_table) — #82 +// Barre de défilement horizontale pour les tableaux (.marches_table) — #82 (function () { "use strict"; - // Renvoie le conteneur réellement scrollable horizontalement du tableau. - function getScrollEl(wrapper) { - return wrapper.querySelector(".dash-spreadsheet-container") || wrapper; - } - function setup(wrapper) { if (wrapper.dataset.hscrollReady === "1") return; - const scrollEl = getScrollEl(wrapper); + const dashContainer = wrapper.querySelector(".dash-spreadsheet-container"); + if (!dashContainer) return; + + // Injecter le conteneur scrollable horizontal autour du conteneur Dash + const scrollWrapper = document.createElement("div"); + scrollWrapper.className = "dt-scroll-wrapper"; + dashContainer.parentNode.insertBefore(scrollWrapper, dashContainer); + scrollWrapper.appendChild(dashContainer); + + // Injecter la barre de défilement en haut, avant le wrapper scrollable const bar = document.createElement("div"); bar.className = "dt-hscroll is-hidden"; const inner = document.createElement("div"); inner.className = "dt-hscroll-inner"; bar.appendChild(inner); - wrapper.insertBefore(bar, wrapper.firstChild); + wrapper.insertBefore(bar, scrollWrapper); // syncing évite les boucles dans la même pile d'exécution ; l'affectation de scrollLeft // déclenche l'événement scroll de façon synchrone dans Chromium. @@ -24,30 +28,34 @@ const onBar = () => { if (syncing) return; syncing = true; - scrollEl.scrollLeft = bar.scrollLeft; + scrollWrapper.scrollLeft = bar.scrollLeft; syncing = false; }; const onTable = () => { if (syncing) return; syncing = true; - bar.scrollLeft = scrollEl.scrollLeft; + bar.scrollLeft = scrollWrapper.scrollLeft; syncing = false; }; bar.addEventListener("scroll", onBar); - scrollEl.addEventListener("scroll", onTable); + scrollWrapper.addEventListener("scroll", onTable); const refresh = () => { - const total = scrollEl.scrollWidth; - const visible = scrollEl.clientWidth; + const total = scrollWrapper.scrollWidth; + const visible = scrollWrapper.clientWidth; inner.style.width = total + "px"; // +1 absorbe les erreurs d'arrondi sous-pixel bar.classList.toggle("is-hidden", total <= visible + 1); - bar.scrollLeft = scrollEl.scrollLeft; + bar.scrollLeft = scrollWrapper.scrollLeft; }; // Recalcule quand le tableau change (pagination, tri, filtre, données). const obs = new MutationObserver(() => refresh()); - obs.observe(scrollEl, { childList: true, subtree: true, attributes: true }); + obs.observe(dashContainer, { + childList: true, + subtree: true, + attributes: true, + }); // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. window.addEventListener("resize", refresh); From 39dfa22b0d545b76151fa49d8170a0c75be32870 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 17:00:28 +0200 Subject: [PATCH 134/151] =?UTF-8?q?Plan=20:=20am=C3=A9lioration=20du=20sty?= =?UTF-8?q?le=20des=20exports=20Excel=20#83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-06-23-excel-export-styling.md | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-excel-export-styling.md diff --git a/docs/superpowers/plans/2026-06-23-excel-export-styling.md b/docs/superpowers/plans/2026-06-23-excel-export-styling.md new file mode 100644 index 0000000..0adc172 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-excel-export-styling.md @@ -0,0 +1,321 @@ +# Excel Export Styling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Améliorer les exports Excel de toutes les vues tableau : en-têtes stylisés (fond rouge `#b33821`, texte blanc, gras), largeurs de colonnes issues du `DataTable` commun, largeur minimale de 3.5 cm, et retour à la ligne automatique dans toutes les cellules. + +**Architecture:** Une fonction utilitaire `write_styled_excel(df, buffer, worksheet)` centralisée dans `src/utils/table.py` crée le workbook xlsxwriter avec `default_format_properties: {text_wrap: True}`, puis délègue à `polars.DataFrame.write_excel` avec le `header_format` et les `column_widths` calculés. Les 6 callbacks de téléchargement appellent cette fonction à la place de `.write_excel()` direct. + +**Tech Stack:** Python, Polars, xlsxwriter (déjà en dep), openpyxl (disponible, utilisé en test uniquement) + +## Global Constraints + +- Imports de modules internes toujours via `src.` (ex. `from src.utils.table import …`) +- `xlsxwriter` est en dep de prod — pas besoin de l'ajouter +- `openpyxl` est disponible (dep transitive) — pas besoin de l'ajouter à `pyproject.toml` +- Run tests : `uv run pytest` (pas `pytest` direct) +- Couleur primaire de l'app : `#b33821` +- Largeur minimale : 132 pixels (≈ 3.5 cm à 96 DPI) + +--- + +## File Map + +| Fichier | Action | Rôle | +| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | +| `src/utils/table.py` | Modifier | Ajouter `import xlsxwriter`, constantes `_EXCEL_*`, fonction `write_styled_excel` | +| `tests/test_excel.py` | Créer | Tests unitaires de `write_styled_excel` | +| `src/pages/tableau.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_data` | +| `src/pages/acheteur.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_acheteur_data` et `download_filtered_acheteur_data` | +| `src/pages/titulaire.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_titulaire_data` et `download_filtered_titulaire_data` | +| `src/pages/observatoire.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_observatoire` | + +--- + +### Task 1 : `write_styled_excel` dans `src/utils/table.py` + +**Files:** + +- Modify: `src/utils/table.py:1` (ajouter import), `src/utils/table.py:559` (fin de fichier) +- Create: `tests/test_excel.py` + +**Interfaces:** + +- Produces: `write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None` — exporté publiquement depuis `src.utils.table` + +- [ ] **Step 1 : Créer `tests/test_excel.py` avec les tests en échec** + +```python +import io + +import openpyxl +import polars as pl +import pytest + +from src.utils.table import write_styled_excel + + +def test_write_styled_excel_header_bold_and_color(): + df = pl.DataFrame({"objet": ["marché A"], "montant": [1000.0]}) + buf = io.BytesIO() + write_styled_excel(df, buf) + buf.seek(0) + wb = openpyxl.load_workbook(buf) + ws = wb.active + cell = ws.cell(row=1, column=1) + assert cell.font.bold is True + assert cell.fill.fgColor.rgb == "FFB33821" + assert cell.font.color.rgb == "FFFFFFFF" + + +def test_write_styled_excel_data_cell_text_wrap(): + df = pl.DataFrame({"objet": ["marché A"]}) + buf = io.BytesIO() + write_styled_excel(df, buf) + buf.seek(0) + wb = openpyxl.load_workbook(buf) + ws = wb.active + cell = ws.cell(row=2, column=1) + assert cell.alignment.wrap_text is True + + +def test_write_styled_excel_known_column_wider_than_minimum(): + # "objet" a une largeur explicite (350px) > minimum (132px) + # "autre" n'a pas de largeur explicite → reçoit le minimum + df = pl.DataFrame({"autre": ["x"], "objet": ["y"]}) + buf = io.BytesIO() + write_styled_excel(df, buf) + buf.seek(0) + wb = openpyxl.load_workbook(buf) + ws = wb.active + width_autre = ws.column_dimensions["A"].width # colonne 1 = "autre" + width_objet = ws.column_dimensions["B"].width # colonne 2 = "objet" + assert width_autre > 0 + assert width_objet > width_autre + + +def test_write_styled_excel_custom_worksheet_name(): + df = pl.DataFrame({"col": ["val"]}) + buf = io.BytesIO() + write_styled_excel(df, buf, worksheet="2025") + buf.seek(0) + wb = openpyxl.load_workbook(buf) + assert "2025" in wb.sheetnames +``` + +- [ ] **Step 2 : Vérifier que les tests échouent** + +```bash +uv run pytest tests/test_excel.py -v +``` + +Attendu : `ImportError` ou `AttributeError: module 'src.utils.table' has no attribute 'write_styled_excel'` + +- [ ] **Step 3 : Ajouter `import xlsxwriter` dans `src/utils/table.py`** + +Ligne 1 du fichier, après les imports existants. Le bloc imports actuel (lignes 1–14) devient : + +```python +import os +import uuid + +import polars as pl +import xlsxwriter +from dash import no_update +from polars import selectors as cs +from unidecode import unidecode + +from src.db import count_marches, count_unique_marches, query_marches, schema +from src.utils import logger +from src.utils.cache import cache +from src.utils.data import DATA_SCHEMA +from src.utils.frontend import get_button_properties +from src.utils.tracking import track_search +``` + +- [ ] **Step 4 : Ajouter les constantes et `write_styled_excel` à la fin de `src/utils/table.py`** + +Après la ligne `COLUMNS = schema.names()` (actuellement dernière ligne, 559) : + +```python + +_EXCEL_MIN_COLUMN_WIDTH = 132 # ≈ 3.5 cm à 96 DPI +_EXCEL_HEADER_FORMAT = { + "bold": True, + "bg_color": "#b33821", + "font_color": "white", +} +_EXCEL_COLUMN_WIDTHS = { + "objet": 350, + "acheteur_nom": 250, + "titulaire_nom": 250, + "acheteur_id": 160, +} + + +def write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None: + col_widths = { + col: max(_EXCEL_MIN_COLUMN_WIDTH, _EXCEL_COLUMN_WIDTHS.get(col, 0)) + for col in df.columns + } + wb = xlsxwriter.Workbook(buffer, {"default_format_properties": {"text_wrap": True}}) + ws = wb.add_worksheet(worksheet) + df.write_excel( + workbook=wb, + worksheet=ws, + header_format=_EXCEL_HEADER_FORMAT, + column_widths=col_widths, + ) + wb.close() +``` + +- [ ] **Step 5 : Vérifier que les tests passent** + +```bash +uv run pytest tests/test_excel.py -v +``` + +Attendu : 4 tests PASS + +- [ ] **Step 6 : Commit** + +```bash +git add src/utils/table.py tests/test_excel.py +git commit -m "feat: add write_styled_excel utility for styled Excel exports #83" +``` + +--- + +### Task 2 : Mettre à jour les 6 callbacks de téléchargement + +**Files:** + +- Modify: `src/pages/tableau.py:26-33,344-345` +- Modify: `src/pages/acheteur.py:30-37,399-402,441-442` +- Modify: `src/pages/titulaire.py:29-36,421-423,462-463` +- Modify: `src/pages/observatoire.py:43,813-814` + +**Interfaces:** + +- Consumes: `write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None` depuis Task 1 + +- [ ] **Step 1 : Mettre à jour `src/pages/tableau.py`** + +Ajouter `write_styled_excel` à l'import existant (lignes 26–33) : + +```python +from src.utils.table import ( + COLUMNS, + filter_table_data, + get_default_hidden_columns, + invert_columns, + prepare_table_data, + sort_table_data, + write_styled_excel, +) +``` + +Remplacer le bloc `def to_bytes` dans `download_data` (lignes 344–345) : + +```python + def to_bytes(buffer): + write_styled_excel(lff.collect(engine="streaming"), buffer) +``` + +- [ ] **Step 2 : Mettre à jour `src/pages/acheteur.py`** + +Ajouter `write_styled_excel` à l'import existant (lignes 30–37) : + +```python +from src.utils.table import ( + COLUMNS, + filter_table_data, + format_number, + get_default_hidden_columns, + prepare_table_data, + sort_table_data, + write_styled_excel, +) +``` + +Remplacer le bloc `def to_bytes` dans `download_acheteur_data` (lignes 399–402) : + +```python + def to_bytes(buffer): + write_styled_excel( + df_to_download, + buffer, + worksheet="DECP" if annee in ["Toutes les années", None] else annee, + ) +``` + +Remplacer le bloc `def to_bytes` dans `download_filtered_acheteur_data` (ligne 441–442) : + +```python + def to_bytes(buffer): + write_styled_excel(lff.collect(engine="streaming"), buffer) +``` + +- [ ] **Step 3 : Mettre à jour `src/pages/titulaire.py`** + +Ajouter `write_styled_excel` à l'import existant (lignes 29–36) : + +```python +from src.utils.table import ( + COLUMNS, + filter_table_data, + format_number, + get_default_hidden_columns, + prepare_table_data, + sort_table_data, + write_styled_excel, +) +``` + +Remplacer le bloc `def to_bytes` dans `download_titulaire_data` (lignes 421–423) : + +```python + def to_bytes(buffer): + write_styled_excel( + df_to_download, + buffer, + worksheet="DECP" if annee in ["Toutes les années", None] else annee, + ) +``` + +Remplacer le bloc `def to_bytes` dans `download_filtered_titulaire_data` (lignes 462–463) : + +```python + def to_bytes(buffer): + write_styled_excel(lff.collect(engine="streaming"), buffer) +``` + +- [ ] **Step 4 : Mettre à jour `src/pages/observatoire.py`** + +Modifier l'import (ligne 43) : + +```python +from src.utils.table import COLUMNS, get_default_hidden_columns, prepare_table_data, write_styled_excel +``` + +Remplacer le bloc `def to_bytes` dans `download_observatoire` (ligne 813–814) : + +```python + def to_bytes(buffer): + write_styled_excel(dff, buffer) +``` + +- [ ] **Step 5 : Vérifier les tests existants et les nouveaux** + +```bash +uv run pytest tests/test_excel.py tests/test_main.py::test_003_tableau_download -v +``` + +Attendu : tous PASS (le test existant vérifie que le contenu base64 est non vide et que le nom de fichier commence par `decp_` — comportement inchangé) + +- [ ] **Step 6 : Commit** + +```bash +git add src/pages/tableau.py src/pages/acheteur.py src/pages/titulaire.py src/pages/observatoire.py +git commit -m "refactor: use write_styled_excel in all download callbacks #83" +``` From 35e72645bde8cb46fb279902ad245ae1fd10e472 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 17:05:55 +0200 Subject: [PATCH 135/151] feat: add write_styled_excel utility for styled Excel exports #83 Co-Authored-By: Claude Sonnet 4.6 --- src/utils/table.py | 30 +++++++++++++++++++++++++ tests/test_excel.py | 54 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tests/test_excel.py diff --git a/src/utils/table.py b/src/utils/table.py index 52b07e4..4790410 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -2,6 +2,7 @@ import os import uuid import polars as pl +import xlsxwriter from dash import no_update from polars import selectors as cs from unidecode import unidecode @@ -557,3 +558,32 @@ def invert_columns(columns): COLUMNS = schema.names() + +_EXCEL_MIN_COLUMN_WIDTH = 132 # ≈ 3.5 cm à 96 DPI +_EXCEL_HEADER_FORMAT = { + "bold": True, + "bg_color": "#b33821", + "font_color": "white", +} +_EXCEL_COLUMN_WIDTHS = { + "objet": 350, + "acheteur_nom": 250, + "titulaire_nom": 250, + "acheteur_id": 160, +} + + +def write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None: + col_widths = { + col: max(_EXCEL_MIN_COLUMN_WIDTH, _EXCEL_COLUMN_WIDTHS.get(col, 0)) + for col in df.columns + } + wb = xlsxwriter.Workbook(buffer, {"default_format_properties": {"text_wrap": True}}) + ws = wb.add_worksheet(worksheet) + df.write_excel( + workbook=wb, + worksheet=ws, + header_format=_EXCEL_HEADER_FORMAT, + column_widths=col_widths, + ) + wb.close() diff --git a/tests/test_excel.py b/tests/test_excel.py new file mode 100644 index 0000000..9f9b022 --- /dev/null +++ b/tests/test_excel.py @@ -0,0 +1,54 @@ +import io + +import openpyxl +import polars as pl + +from src.utils.table import write_styled_excel + + +def test_write_styled_excel_header_bold_and_color(): + df = pl.DataFrame({"objet": ["marché A"], "montant": [1000.0]}) + buf = io.BytesIO() + write_styled_excel(df, buf) + buf.seek(0) + wb = openpyxl.load_workbook(buf) + ws = wb.active + cell = ws.cell(row=1, column=1) + assert cell.font.bold is True + assert cell.fill.fgColor.rgb == "FFB33821" + assert cell.font.color.rgb == "FFFFFFFF" + + +def test_write_styled_excel_data_cell_text_wrap(): + df = pl.DataFrame({"objet": ["marché A"]}) + buf = io.BytesIO() + write_styled_excel(df, buf) + buf.seek(0) + wb = openpyxl.load_workbook(buf) + ws = wb.active + cell = ws.cell(row=2, column=1) + assert cell.alignment.wrap_text is True + + +def test_write_styled_excel_known_column_wider_than_minimum(): + # "objet" a une largeur explicite (350px) > minimum (132px) + # "autre" n'a pas de largeur explicite → reçoit le minimum + df = pl.DataFrame({"autre": ["x"], "objet": ["y"]}) + buf = io.BytesIO() + write_styled_excel(df, buf) + buf.seek(0) + wb = openpyxl.load_workbook(buf) + ws = wb.active + width_autre = ws.column_dimensions["A"].width # colonne 1 = "autre" + width_objet = ws.column_dimensions["B"].width # colonne 2 = "objet" + assert width_autre > 0 + assert width_objet > width_autre + + +def test_write_styled_excel_custom_worksheet_name(): + df = pl.DataFrame({"col": ["val"]}) + buf = io.BytesIO() + write_styled_excel(df, buf, worksheet="2025") + buf.seek(0) + wb = openpyxl.load_workbook(buf) + assert "2025" in wb.sheetnames From e5ca7d62a33d7a25ba56172baa348ef162655fc4 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 17:08:32 +0200 Subject: [PATCH 136/151] fix: protect wb.close() with try/finally, strengthen width assertions, declare openpyxl dev dep #83 - I-1: Add explicit lower bounds to width assertions (>= 15 for autre, >= 40 for objet) - I-2: Wrap write_excel() and close() in try/finally to ensure cleanup even on exception - M-2: Add openpyxl to dev dependencies in pyproject.toml Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 1 + src/utils/table.py | 18 ++++++++++-------- tests/test_excel.py | 4 ++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bbe0206..5ae6ab6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dev = [ "webdriver-manager", "dash[testing]", "fastexcel", + "openpyxl", ] [tool.pytest.ini_options] diff --git a/src/utils/table.py b/src/utils/table.py index 4790410..1a96289 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -579,11 +579,13 @@ def write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> Non for col in df.columns } wb = xlsxwriter.Workbook(buffer, {"default_format_properties": {"text_wrap": True}}) - ws = wb.add_worksheet(worksheet) - df.write_excel( - workbook=wb, - worksheet=ws, - header_format=_EXCEL_HEADER_FORMAT, - column_widths=col_widths, - ) - wb.close() + try: + ws = wb.add_worksheet(worksheet) + df.write_excel( + workbook=wb, + worksheet=ws, + header_format=_EXCEL_HEADER_FORMAT, + column_widths=col_widths, + ) + finally: + wb.close() diff --git a/tests/test_excel.py b/tests/test_excel.py index 9f9b022..711c276 100644 --- a/tests/test_excel.py +++ b/tests/test_excel.py @@ -41,8 +41,8 @@ def test_write_styled_excel_known_column_wider_than_minimum(): ws = wb.active width_autre = ws.column_dimensions["A"].width # colonne 1 = "autre" width_objet = ws.column_dimensions["B"].width # colonne 2 = "objet" - assert width_autre > 0 - assert width_objet > width_autre + assert width_autre >= 15 # 132px minimum → ~18.9 chars xlsxwriter, borne basse 15 + assert width_objet >= 40 # 350px → ~50 chars xlsxwriter, borne basse 40 def test_write_styled_excel_custom_worksheet_name(): From a5e802d20afeee7ab385cb02306b0a3f9d0bb4a9 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 17:14:16 +0200 Subject: [PATCH 137/151] refactor: use write_styled_excel in all download callbacks #83 --- src/pages/acheteur.py | 9 ++++++--- src/pages/observatoire.py | 9 +++++++-- src/pages/tableau.py | 3 ++- src/pages/titulaire.py | 9 ++++++--- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 23bf203..ab43ee7 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -34,6 +34,7 @@ from src.utils.table import ( get_default_hidden_columns, prepare_table_data, sort_table_data, + write_styled_excel, ) from src.utils.tracking import track_search @@ -397,8 +398,10 @@ def download_acheteur_data( df_to_download = pl.DataFrame(data) def to_bytes(buffer): - df_to_download.write_excel( - buffer, worksheet="DECP" if annee in ["Toutes les années", None] else annee + write_styled_excel( + df_to_download, + buffer, + worksheet="DECP" if annee in ["Toutes les années", None] else annee, ) date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") @@ -439,7 +442,7 @@ def download_filtered_acheteur_data( lff = sort_table_data(lff, sort_by) def to_bytes(buffer): - lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP") + write_styled_excel(lff.collect(engine="streaming"), buffer) date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") return dcc.send_bytes( diff --git a/src/pages/observatoire.py b/src/pages/observatoire.py index 7dc85f9..e1c7592 100644 --- a/src/pages/observatoire.py +++ b/src/pages/observatoire.py @@ -40,7 +40,12 @@ from src.utils.data import ( ) from src.utils.frontend import get_enum_values_as_dict from src.utils.seo import META_CONTENT -from src.utils.table import COLUMNS, get_default_hidden_columns, prepare_table_data +from src.utils.table import ( + COLUMNS, + get_default_hidden_columns, + prepare_table_data, + write_styled_excel, +) NAME = "Observatoire" @@ -811,7 +816,7 @@ def download_observatoire(_n_clicks, filter_params, hidden_columns): dff = dff.drop(hidden_columns) def to_bytes(buffer): - dff.write_excel(buffer, worksheet="DECP") + write_styled_excel(dff, buffer) date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx") diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 1a272ff..56bdf00 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -30,6 +30,7 @@ from src.utils.table import ( invert_columns, prepare_table_data, sort_table_data, + write_styled_excel, ) from src.utils.tracking import track_search @@ -342,7 +343,7 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list | None = lff = sort_table_data(lff, sort_by) def to_bytes(buffer): - lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP") + write_styled_excel(lff.collect(engine="streaming"), buffer) date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx") diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 3edb8ba..e30022d 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -33,6 +33,7 @@ from src.utils.table import ( get_default_hidden_columns, prepare_table_data, sort_table_data, + write_styled_excel, ) from src.utils.tracking import track_search @@ -418,8 +419,10 @@ def download_titulaire_data( df_to_download = pl.DataFrame(data) def to_bytes(buffer): - df_to_download.write_excel( - buffer, worksheet="DECP" if annee in ["Toutes les années", None] else annee + write_styled_excel( + df_to_download, + buffer, + worksheet="DECP" if annee in ["Toutes les années", None] else annee, ) date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") @@ -460,7 +463,7 @@ def download_filtered_titulaire_data( lff = sort_table_data(lff, sort_by) def to_bytes(buffer): - lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP") + write_styled_excel(lff.collect(engine="streaming"), buffer) date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") return dcc.send_bytes( From 1d27d517f317a7ab757ccafae39f7f8fa24f2caf Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 17:33:09 +0200 Subject: [PATCH 138/151] fix(tableaux): garde hscrollReady avant manipulation DOM #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Évite la boucle infinie : le rootObs se déclenchait sur les insertions DOM de setup() avant que la garde ne soit posée, relançant setup() en boucle et faisant monter le CPU. Co-Authored-By: Claude Opus 4.8 --- src/assets/table_hscroll.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 829db34..908b2aa 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -4,6 +4,7 @@ function setup(wrapper) { if (wrapper.dataset.hscrollReady === "1") return; + wrapper.dataset.hscrollReady = "1"; const dashContainer = wrapper.querySelector(".dash-spreadsheet-container"); if (!dashContainer) return; @@ -59,7 +60,6 @@ // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. window.addEventListener("resize", refresh); - wrapper.dataset.hscrollReady = "1"; refresh(); } From 817aeedc6f20603a41f0ed17a28cd5b6688393a8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 17:42:50 +0200 Subject: [PATCH 139/151] =?UTF-8?q?fix(tableaux):=20garde=20hscrollReady?= =?UTF-8?q?=20apr=C3=A8s=20v=C3=A9rification=20dashContainer=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Si la garde est posée avant de vérifier que dashContainer existe, un déclenchement prématuré de rootObs (avant le rendu Dash) marque le wrapper comme traité sans jamais injecter le scroll wrapper. Co-Authored-By: Claude Opus 4.8 --- src/assets/table_hscroll.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 908b2aa..29bdaf7 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -4,11 +4,14 @@ function setup(wrapper) { if (wrapper.dataset.hscrollReady === "1") return; - wrapper.dataset.hscrollReady = "1"; const dashContainer = wrapper.querySelector(".dash-spreadsheet-container"); if (!dashContainer) return; + // Garde posée après la vérification de dashContainer, avant toute manipulation DOM + // qui déclencherait rootObs et provoquerait une re-entrée dans setup(). + wrapper.dataset.hscrollReady = "1"; + // Injecter le conteneur scrollable horizontal autour du conteneur Dash const scrollWrapper = document.createElement("div"); scrollWrapper.className = "dt-scroll-wrapper"; From 099fba58f709dcca8b510d62a8b2f9eb872f3be8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 18:22:08 +0200 Subject: [PATCH 140/151] =?UTF-8?q?fix(tableaux):=20option=20A=20=E2=80=94?= =?UTF-8?q?=20sync=20barre=20avec=20window.scrollX=20+=20style=20orange=20?= =?UTF-8?q?#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Abandonne le wrapper overflow-x (incompatible avec position:sticky des en-têtes). La barre contrôle désormais le scroll horizontal de la page via window.scrollTo/scrollX, ce qui préserve le sticky des th. Scrollbar orange 12px, styling webkit + Firefox. Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 27 +++++++++++++++++---------- src/assets/table_hscroll.js | 30 +++++++++++++----------------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index a98f098..c62ad16 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -379,14 +379,29 @@ table.cell-table th { background-color: rgb(255 240 240 / 40%); } -/* Barre de défilement horizontale miroir, collée en haut */ +/* Barre de défilement horizontale, collée en haut au-dessus des en-têtes */ .marches_table .dt-hscroll { position: sticky; top: 0; z-index: 11; /* au-dessus des en-têtes sticky */ overflow-x: auto; overflow-y: hidden; - height: 14px; + height: 12px; + scrollbar-color: orange #e0e0e0; + scrollbar-width: thin; +} + +.marches_table .dt-hscroll::-webkit-scrollbar { + height: 12px; +} + +.marches_table .dt-hscroll::-webkit-scrollbar-thumb { + background-color: orange; + border-radius: 6px; +} + +.marches_table .dt-hscroll::-webkit-scrollbar-track { + background-color: #e0e0e0; } .marches_table .dt-hscroll-inner { @@ -397,14 +412,6 @@ table.cell-table th { display: none; } -/* Conteneur scrollable horizontal — overflow-y:clip ne crée pas de scroll container vertical */ -/* donc position:sticky sur th.dash-header reste calé sur la page */ -.marches_table .dt-scroll-wrapper { - overflow-x: auto; - overflow-y: clip; - width: 100%; -} - /* Column Visibility Menu */ .column-actions { margin-right: 8px; diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 29bdaf7..3d518cd 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -12,45 +12,41 @@ // qui déclencherait rootObs et provoquerait une re-entrée dans setup(). wrapper.dataset.hscrollReady = "1"; - // Injecter le conteneur scrollable horizontal autour du conteneur Dash - const scrollWrapper = document.createElement("div"); - scrollWrapper.className = "dt-scroll-wrapper"; - dashContainer.parentNode.insertBefore(scrollWrapper, dashContainer); - scrollWrapper.appendChild(dashContainer); - - // Injecter la barre de défilement en haut, avant le wrapper scrollable const bar = document.createElement("div"); bar.className = "dt-hscroll is-hidden"; const inner = document.createElement("div"); inner.className = "dt-hscroll-inner"; bar.appendChild(inner); - wrapper.insertBefore(bar, scrollWrapper); + wrapper.insertBefore(bar, wrapper.firstChild); - // syncing évite les boucles dans la même pile d'exécution ; l'affectation de scrollLeft - // déclenche l'événement scroll de façon synchrone dans Chromium. + // La barre synchronise le scroll horizontal de la page (overflow: visible sur les + // conteneurs Dash laisse le scroll se faire au niveau de la page, ce qui permet + // aux en-têtes position:sticky de rester calés sur la fenêtre). + // syncing évite les boucles dans la même pile d'exécution. let syncing = false; const onBar = () => { if (syncing) return; syncing = true; - scrollWrapper.scrollLeft = bar.scrollLeft; + window.scrollTo(bar.scrollLeft, window.scrollY); syncing = false; }; - const onTable = () => { + const onPage = () => { if (syncing) return; syncing = true; - bar.scrollLeft = scrollWrapper.scrollLeft; + bar.scrollLeft = window.scrollX; syncing = false; }; bar.addEventListener("scroll", onBar); - scrollWrapper.addEventListener("scroll", onTable); + // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. + window.addEventListener("scroll", onPage); const refresh = () => { - const total = scrollWrapper.scrollWidth; - const visible = scrollWrapper.clientWidth; + const total = document.documentElement.scrollWidth; + const visible = window.innerWidth; inner.style.width = total + "px"; // +1 absorbe les erreurs d'arrondi sous-pixel bar.classList.toggle("is-hidden", total <= visible + 1); - bar.scrollLeft = scrollWrapper.scrollLeft; + bar.scrollLeft = window.scrollX; }; // Recalcule quand le tableau change (pagination, tri, filtre, données). From 0edca05df5dc232d87d3ce5d03991265a1bed71e Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 18:26:36 +0200 Subject: [PATCH 141/151] =?UTF-8?q?fix(tableaux):=20barre=2016px=20toujour?= =?UTF-8?q?s=20visible,=20headers=20d=C3=A9cal=C3=A9s=20en=20dessous=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overflow-x:scroll force le thumb orange toujours visible (pas overlay) - hauteur 16px pour le drag-and-drop - :has(.dt-hscroll:not(.is-hidden)) décale les th à top:16px quand la barre est présente, évitant le recouvrement Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index c62ad16..cb405a8 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -375,6 +375,11 @@ table.cell-table th { background-color: #fff; } +/* Quand la barre de scroll est visible, pousser les en-têtes en dessous */ +.marches_table:has(.dt-hscroll:not(.is-hidden)) th.dash-header { + top: 16px; +} + .marches_table .cell-table tr:nth-child(even) td { background-color: rgb(255 240 240 / 40%); } @@ -384,20 +389,20 @@ table.cell-table th { position: sticky; top: 0; z-index: 11; /* au-dessus des en-têtes sticky */ - overflow-x: auto; + overflow-x: scroll; /* scroll (pas auto) force le thumb toujours visible */ overflow-y: hidden; - height: 12px; + height: 16px; scrollbar-color: orange #e0e0e0; - scrollbar-width: thin; + scrollbar-width: auto; } .marches_table .dt-hscroll::-webkit-scrollbar { - height: 12px; + height: 16px; } .marches_table .dt-hscroll::-webkit-scrollbar-thumb { background-color: orange; - border-radius: 6px; + border-radius: 8px; } .marches_table .dt-hscroll::-webkit-scrollbar-track { From 9c5fc9257afc545eae50e9ffe91a23649e328ecc Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 18:50:51 +0200 Subject: [PATCH 142/151] fix(tableaux): thumb custom toujours visible, drag souris et tactile #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace le scrollbar natif (invisible en mode overlay sous Linux) par un thumb div orange toujours visible. Track gris (#e0e0e0) 16px. Drag souris + toucher + clic sur le track supportés. Supprime la dépendance aux pseudo-éléments webkit et scrollbar-color. Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 38 ++++++------ src/assets/table_hscroll.js | 120 +++++++++++++++++++++++++++--------- 2 files changed, 109 insertions(+), 49 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index cb405a8..81f210c 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -389,34 +389,32 @@ table.cell-table th { position: sticky; top: 0; z-index: 11; /* au-dessus des en-têtes sticky */ - overflow-x: scroll; /* scroll (pas auto) force le thumb toujours visible */ - overflow-y: hidden; height: 16px; - scrollbar-color: orange #e0e0e0; - scrollbar-width: auto; -} - -.marches_table .dt-hscroll::-webkit-scrollbar { - height: 16px; -} - -.marches_table .dt-hscroll::-webkit-scrollbar-thumb { - background-color: orange; - border-radius: 8px; -} - -.marches_table .dt-hscroll::-webkit-scrollbar-track { background-color: #e0e0e0; -} - -.marches_table .dt-hscroll-inner { - height: 1px; + overflow: hidden; + cursor: pointer; } .marches_table .dt-hscroll.is-hidden { display: none; } +/* Thumb custom — toujours visible, draggable */ +.marches_table .dt-hscroll-thumb { + position: absolute; + top: 2px; + height: calc(100% - 4px); + min-width: 40px; + background-color: orange; + border-radius: 8px; + cursor: grab; + user-select: none; +} + +.marches_table .dt-hscroll-thumb:active { + cursor: grabbing; +} + /* Column Visibility Menu */ .column-actions { margin-right: 8px; diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 3d518cd..b02e438 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -14,39 +14,101 @@ const bar = document.createElement("div"); bar.className = "dt-hscroll is-hidden"; - const inner = document.createElement("div"); - inner.className = "dt-hscroll-inner"; - bar.appendChild(inner); + const thumb = document.createElement("div"); + thumb.className = "dt-hscroll-thumb"; + bar.appendChild(thumb); wrapper.insertBefore(bar, wrapper.firstChild); - // La barre synchronise le scroll horizontal de la page (overflow: visible sur les - // conteneurs Dash laisse le scroll se faire au niveau de la page, ce qui permet - // aux en-têtes position:sticky de rester calés sur la fenêtre). - // syncing évite les boucles dans la même pile d'exécution. - let syncing = false; - const onBar = () => { - if (syncing) return; - syncing = true; - window.scrollTo(bar.scrollLeft, window.scrollY); - syncing = false; - }; - const onPage = () => { - if (syncing) return; - syncing = true; - bar.scrollLeft = window.scrollX; - syncing = false; - }; - bar.addEventListener("scroll", onBar); - // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. - window.addEventListener("scroll", onPage); - - const refresh = () => { + // --- Métriques communes --- + const metrics = () => { const total = document.documentElement.scrollWidth; const visible = window.innerWidth; - inner.style.width = total + "px"; - // +1 absorbe les erreurs d'arrondi sous-pixel - bar.classList.toggle("is-hidden", total <= visible + 1); - bar.scrollLeft = window.scrollX; + const trackW = bar.clientWidth; + const thumbW = Math.max(40, (visible / total) * trackW); + const scrollRange = total - visible; + const thumbRange = trackW - thumbW; + return { total, visible, trackW, thumbW, scrollRange, thumbRange }; + }; + + // --- Mise à jour de la position du thumb --- + const syncThumb = () => { + const { total, visible, thumbW, scrollRange, thumbRange } = metrics(); + if (total <= visible + 1 || thumbRange <= 0) return; + thumb.style.width = thumbW + "px"; + const fraction = scrollRange > 0 ? window.scrollX / scrollRange : 0; + thumb.style.left = Math.round(fraction * thumbRange) + "px"; + }; + + // --- Drag souris + tactile --- + let dragStartX = null; + let dragScrollStart = null; + + const startDrag = (clientX) => { + dragStartX = clientX; + dragScrollStart = window.scrollX; + }; + const moveDrag = (clientX) => { + if (dragStartX === null) return; + const dx = clientX - dragStartX; + const { scrollRange, thumbRange } = metrics(); + if (thumbRange <= 0) return; + window.scrollTo( + dragScrollStart + (dx / thumbRange) * scrollRange, + window.scrollY + ); + }; + const endDrag = () => { + dragStartX = null; + }; + + thumb.addEventListener("mousedown", (e) => { + startDrag(e.clientX); + e.preventDefault(); + }); + document.addEventListener("mousemove", (e) => moveDrag(e.clientX)); + document.addEventListener("mouseup", endDrag); + + thumb.addEventListener( + "touchstart", + (e) => { + startDrag(e.touches[0].clientX); + e.preventDefault(); + }, + { passive: false } + ); + document.addEventListener( + "touchmove", + (e) => { + if (dragStartX !== null) { + moveDrag(e.touches[0].clientX); + e.preventDefault(); + } + }, + { passive: false } + ); + document.addEventListener("touchend", endDrag); + + // Clic sur le track (hors thumb) : saute à la position cliquée. + bar.addEventListener("click", (e) => { + if (e.target === thumb) return; + const rect = bar.getBoundingClientRect(); + const { scrollRange, thumbW, thumbRange } = metrics(); + const fraction = Math.max( + 0, + Math.min(1, (e.clientX - rect.left - thumbW / 2) / thumbRange) + ); + window.scrollTo(fraction * scrollRange, window.scrollY); + }); + + // Synchronise le thumb quand la page défile (via clavier, molette, etc.). + // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. + window.addEventListener("scroll", syncThumb); + + const refresh = () => { + const { total, visible } = metrics(); + const hasOverflow = total > visible + 1; + bar.classList.toggle("is-hidden", !hasOverflow); + if (hasOverflow) syncThumb(); }; // Recalcule quand le tableau change (pagination, tri, filtre, données). From 3c0cf6bc0743499af724e1f541e611c91ad7aff5 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 19:02:36 +0200 Subject: [PATCH 143/151] fix(tableaux): barre pleine largeur viewport + masquage par table #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adjustBarWidth() brise le padding Bootstrap (margin-left négatif + width: 100vw) pour que la barre s'étende jusqu'au bord de la fenêtre - metrics() utilise window.innerWidth comme largeur de piste (cohérent avec la barre pleine largeur) - hasOverflow mesure la largeur de .cell-table de CETTE table via getBoundingClientRect(), plutôt que document.documentElement.scrollWidth → les petits tableaux (top10 /acheteur, /titulaire, /observatoire) n'affichent pas de barre s'ils ne débordent pas individuellement Co-Authored-By: Claude Opus 4.8 --- src/assets/table_hscroll.js | 41 +++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index b02e438..3285316 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -19,15 +19,22 @@ bar.appendChild(thumb); wrapper.insertBefore(bar, wrapper.firstChild); - // --- Métriques communes --- + // Étend la barre à toute la largeur du viewport, hors du container Bootstrap. + // wrapper.getBoundingClientRect().left donne le décalage horizontal du conteneur. + const adjustBarWidth = () => { + const left = wrapper.getBoundingClientRect().left; + bar.style.marginLeft = -left + "px"; + bar.style.width = window.innerWidth + "px"; + }; + + // Métriques basées sur window.innerWidth (la barre occupe exactement la largeur du viewport). const metrics = () => { const total = document.documentElement.scrollWidth; const visible = window.innerWidth; - const trackW = bar.clientWidth; - const thumbW = Math.max(40, (visible / total) * trackW); + const thumbW = Math.max(40, (visible / total) * visible); const scrollRange = total - visible; - const thumbRange = trackW - thumbW; - return { total, visible, trackW, thumbW, scrollRange, thumbRange }; + const thumbRange = visible - thumbW; + return { total, visible, thumbW, scrollRange, thumbRange }; }; // --- Mise à jour de la position du thumb --- @@ -91,22 +98,31 @@ // Clic sur le track (hors thumb) : saute à la position cliquée. bar.addEventListener("click", (e) => { if (e.target === thumb) return; - const rect = bar.getBoundingClientRect(); const { scrollRange, thumbW, thumbRange } = metrics(); const fraction = Math.max( 0, - Math.min(1, (e.clientX - rect.left - thumbW / 2) / thumbRange) + Math.min( + 1, + (e.clientX - bar.getBoundingClientRect().left - thumbW / 2) / + thumbRange + ) ); window.scrollTo(fraction * scrollRange, window.scrollY); }); - // Synchronise le thumb quand la page défile (via clavier, molette, etc.). + // Synchronise le thumb quand la page défile (clavier, molette, barre native, etc.). // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. window.addEventListener("scroll", syncThumb); const refresh = () => { - const { total, visible } = metrics(); - const hasOverflow = total > visible + 1; + adjustBarWidth(); + // Mesure la largeur réelle de CETTE table (pas de la page entière) pour + // ne pas afficher la barre sur les petits tableaux d'une page qui a aussi + // un grand tableau (ex. top10 sur /acheteur, /titulaire, /observatoire). + const tableEl = + dashContainer.querySelector(".cell-table") || dashContainer; + const tableWidth = tableEl.getBoundingClientRect().width; + const hasOverflow = tableWidth > window.innerWidth + 1; bar.classList.toggle("is-hidden", !hasOverflow); if (hasOverflow) syncThumb(); }; @@ -119,7 +135,10 @@ attributes: true, }); // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. - window.addEventListener("resize", refresh); + window.addEventListener("resize", () => { + adjustBarWidth(); + refresh(); + }); refresh(); } From 2e22fcabe991f2501222e5345b8aaf6f87fdd1d5 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 19:20:41 +0200 Subject: [PATCH 144/151] fix(tableaux): scroll contenu dans dashContainer, plus de scrollbar page #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace le scroll de page (window.scrollTo) par dashContainer.scrollLeft : - overflow-x:hidden sur .dash-spreadsheet-container → la table ne déborde plus la page → scrollbar navigateur en bas éliminée - overflow-y:clip évite la conversion CSS visible→auto (pas de scrollbar vertical parasite sur le conteneur) - metrics(), syncThumb, drag et clic utilisent tous dashContainer - wheel handler capture les gestures trackpad horizontaux et les redirige vers dashContainer.scrollLeft Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 10 ++++- src/assets/table_hscroll.js | 74 ++++++++++++++++++------------------- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 81f210c..9d05feb 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -361,8 +361,14 @@ table.cell-table th { /* ===== Tableaux : en-têtes collants + scroll horizontal (#82) ===== */ -/* Neutraliser l'overflow interne de Dash pour que le sticky se cale sur la page */ -.marches_table .dash-spreadsheet-container, +/* Contenir le scroll horizontal dans le conteneur Dash (élimine la scrollbar native en bas de page) */ +/* overflow-y:clip évite la conversion CSS visible→auto qui ajouterait une scrollbar verticale */ +.marches_table .dash-spreadsheet-container { + overflow-x: hidden !important; + overflow-y: clip !important; +} + +/* L'inner reste visible pour que le tableau se déploie librement en largeur */ .marches_table .dash-spreadsheet-inner { overflow: visible !important; } diff --git a/src/assets/table_hscroll.js b/src/assets/table_hscroll.js index 3285316..f59f142 100644 --- a/src/assets/table_hscroll.js +++ b/src/assets/table_hscroll.js @@ -19,30 +19,24 @@ bar.appendChild(thumb); wrapper.insertBefore(bar, wrapper.firstChild); - // Étend la barre à toute la largeur du viewport, hors du container Bootstrap. - // wrapper.getBoundingClientRect().left donne le décalage horizontal du conteneur. - const adjustBarWidth = () => { - const left = wrapper.getBoundingClientRect().left; - bar.style.marginLeft = -left + "px"; - bar.style.width = window.innerWidth + "px"; - }; - - // Métriques basées sur window.innerWidth (la barre occupe exactement la largeur du viewport). + // Métriques basées sur le conteneur scrollable (pas la page). + // dashContainer a overflow-x:hidden → scrollLeft est contrôlable par JS. const metrics = () => { - const total = document.documentElement.scrollWidth; - const visible = window.innerWidth; + const total = dashContainer.scrollWidth; + const visible = dashContainer.clientWidth; const thumbW = Math.max(40, (visible / total) * visible); const scrollRange = total - visible; const thumbRange = visible - thumbW; return { total, visible, thumbW, scrollRange, thumbRange }; }; - // --- Mise à jour de la position du thumb --- + // Mise à jour de la position du thumb selon dashContainer.scrollLeft. const syncThumb = () => { const { total, visible, thumbW, scrollRange, thumbRange } = metrics(); if (total <= visible + 1 || thumbRange <= 0) return; thumb.style.width = thumbW + "px"; - const fraction = scrollRange > 0 ? window.scrollX / scrollRange : 0; + const fraction = + scrollRange > 0 ? dashContainer.scrollLeft / scrollRange : 0; thumb.style.left = Math.round(fraction * thumbRange) + "px"; }; @@ -52,16 +46,16 @@ const startDrag = (clientX) => { dragStartX = clientX; - dragScrollStart = window.scrollX; + dragScrollStart = dashContainer.scrollLeft; }; const moveDrag = (clientX) => { if (dragStartX === null) return; const dx = clientX - dragStartX; const { scrollRange, thumbRange } = metrics(); if (thumbRange <= 0) return; - window.scrollTo( - dragScrollStart + (dx / thumbRange) * scrollRange, - window.scrollY + dashContainer.scrollLeft = Math.max( + 0, + Math.min(scrollRange, dragScrollStart + (dx / thumbRange) * scrollRange) ); }; const endDrag = () => { @@ -98,31 +92,40 @@ // Clic sur le track (hors thumb) : saute à la position cliquée. bar.addEventListener("click", (e) => { if (e.target === thumb) return; + const rect = bar.getBoundingClientRect(); const { scrollRange, thumbW, thumbRange } = metrics(); const fraction = Math.max( 0, - Math.min( - 1, - (e.clientX - bar.getBoundingClientRect().left - thumbW / 2) / - thumbRange - ) + Math.min(1, (e.clientX - rect.left - thumbW / 2) / thumbRange) ); - window.scrollTo(fraction * scrollRange, window.scrollY); + dashContainer.scrollLeft = fraction * scrollRange; }); - // Synchronise le thumb quand la page défile (clavier, molette, barre native, etc.). + // Scroll molette/trackpad horizontal → redirigé vers le conteneur. // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. - window.addEventListener("scroll", syncThumb); + wrapper.addEventListener( + "wheel", + (e) => { + if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; + e.preventDefault(); + dashContainer.scrollLeft = Math.max( + 0, + Math.min( + dashContainer.scrollWidth - dashContainer.clientWidth, + dashContainer.scrollLeft + e.deltaX + ) + ); + }, + { passive: false } + ); + + // Synchronise le thumb quand le conteneur défile (drag, wheel, ou autre). + // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. + dashContainer.addEventListener("scroll", syncThumb); const refresh = () => { - adjustBarWidth(); - // Mesure la largeur réelle de CETTE table (pas de la page entière) pour - // ne pas afficher la barre sur les petits tableaux d'une page qui a aussi - // un grand tableau (ex. top10 sur /acheteur, /titulaire, /observatoire). - const tableEl = - dashContainer.querySelector(".cell-table") || dashContainer; - const tableWidth = tableEl.getBoundingClientRect().width; - const hasOverflow = tableWidth > window.innerWidth + 1; + const hasOverflow = + dashContainer.scrollWidth > dashContainer.clientWidth + 1; bar.classList.toggle("is-hidden", !hasOverflow); if (hasOverflow) syncThumb(); }; @@ -135,10 +138,7 @@ attributes: true, }); // SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page. - window.addEventListener("resize", () => { - adjustBarWidth(); - refresh(); - }); + window.addEventListener("resize", refresh); refresh(); } From 5e64a4553dd11aceec07c7a3d9f8bb80acf55a86 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 19:46:43 +0200 Subject: [PATCH 145/151] fix(tableaux): supprimer sticky headers, barre 12px #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Retire position:sticky/z-index sur th.dash-header (abandonnés avec le scroll contenu : overflow-x:hidden crée un scroll container qui empêche le sticky page-level, et le z-index provoquait un recouvrement des champs de filtre) - Retire la règle :has() devenue inutile - Barre de scroll réduite à 12px Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 9d05feb..d4a5056 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -373,29 +373,13 @@ table.cell-table th { overflow: visible !important; } -/* En-têtes de colonnes collants en haut de la fenêtre */ -.marches_table th.dash-header { - position: sticky; - top: 0; - z-index: 10; - background-color: #fff; -} - -/* Quand la barre de scroll est visible, pousser les en-têtes en dessous */ -.marches_table:has(.dt-hscroll:not(.is-hidden)) th.dash-header { - top: 16px; -} - .marches_table .cell-table tr:nth-child(even) td { background-color: rgb(255 240 240 / 40%); } -/* Barre de défilement horizontale, collée en haut au-dessus des en-têtes */ +/* Barre de défilement horizontale en haut du tableau */ .marches_table .dt-hscroll { - position: sticky; - top: 0; - z-index: 11; /* au-dessus des en-têtes sticky */ - height: 16px; + height: 12px; background-color: #e0e0e0; overflow: hidden; cursor: pointer; From 45526f7c7b449a4db8e77058dc4f7a34737f0ad6 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 19:52:42 +0200 Subject: [PATCH 146/151] fix(tableaux): restaurer position:sticky sur la barre, 12px #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le retrait de position:sticky sur .dt-hscroll avait cassé le layout (bouton Remettre à zéro visible au mauvais endroit, z-index perdu). La barre redevient sticky top:0 z-index:11 — les th.dash-header restent sans sticky ni z-index (ils ne fonctionnaient de toute façon pas en scroll contenu). Co-Authored-By: Claude Opus 4.8 --- src/assets/css/style.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index d4a5056..50124ab 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -377,8 +377,11 @@ table.cell-table th { background-color: rgb(255 240 240 / 40%); } -/* Barre de défilement horizontale en haut du tableau */ +/* Barre de défilement horizontale, collée en haut du tableau */ .marches_table .dt-hscroll { + position: sticky; + top: 0; + z-index: 11; height: 12px; background-color: #e0e0e0; overflow: hidden; From f508c821c2fb7ff281b4a619744e687637df13d2 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 20:00:41 +0200 Subject: [PATCH 147/151] test(tableaux): adapter test post-abandon sticky headers #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le test ne vérifie plus position:sticky sur th (sticky abandonné car incompatible avec scroll contenu). Vérifie à la place que overflow-x:hidden est bien en place sur le conteneur Dash — garantit l'absence de scrollbar navigateur en bas de page. Co-Authored-By: Claude Opus 4.8 --- tests/test_tableau_hscroll.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_tableau_hscroll.py b/tests/test_tableau_hscroll.py index 6699c89..8bd9920 100644 --- a/tests/test_tableau_hscroll.py +++ b/tests/test_tableau_hscroll.py @@ -2,15 +2,17 @@ from dash.testing.composite import DashComposite def test_tableau_hscroll_bar_present(dash_duo: DashComposite): - """La barre miroir est injectée et l'en-tête est collant sur /tableau.""" + """La barre de défilement est injectée et le conteneur scroll horizontalement.""" from src.app import app dash_duo.start_server(app) dash_duo.wait_for_page(f"{dash_duo.server_url}/tableau") dash_duo.wait_for_element(".marches_table", timeout=20) - # Barre miroir injectée par table_hscroll.js + # Barre injectée par table_hscroll.js dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10) + dash_duo.wait_for_element(".marches_table .dt-hscroll-thumb", timeout=5) - header = dash_duo.find_element(".marches_table th.dash-header") - position = header.value_of_css_property("position") - assert position == "sticky" + # Le conteneur Dash doit avoir overflow-x:hidden (scroll contenu, pas de scrollbar page) + container = dash_duo.find_element(".marches_table .dash-spreadsheet-container") + overflow = container.value_of_css_property("overflow-x") + assert overflow == "hidden" From afdd3c8904a52689fbaaef80e1d43f4a1bc7a486 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 20:16:24 +0200 Subject: [PATCH 148/151] Ajout de openpyxl explicite #83 --- uv.lock | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/uv.lock b/uv.lock index 052ccd3..bf450b4 100644 --- a/uv.lock +++ b/uv.lock @@ -829,6 +829,7 @@ dependencies = [ dev = [ { name = "dash", extra = ["testing"] }, { name = "fastexcel" }, + { name = "openpyxl" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-env" }, @@ -863,6 +864,7 @@ requires-dist = [ dev = [ { name = "dash", extras = ["testing"] }, { name = "fastexcel" }, + { name = "openpyxl" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-env" }, @@ -939,6 +941,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -1647,6 +1658,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "outcome" version = "1.3.0.post0" From d99bb00d6cec508715b77533a056319ba46153b0 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 20:16:51 +0200 Subject: [PATCH 149/151] Compactage --- tests/schema.fixture.json | 240 +++++++++++++++++++------------------- 1 file changed, 122 insertions(+), 118 deletions(-) diff --git a/tests/schema.fixture.json b/tests/schema.fixture.json index fc10e53..a3f4506 100644 --- a/tests/schema.fixture.json +++ b/tests/schema.fixture.json @@ -3,17 +3,17 @@ { "name": "acheteur_categorie", "type": "string", - "title": "Catégorie de l'acheteur", - "description": "Catégorie de l'acheteur selon son code juridique INSEE.", - "short_title": "Catégorie acheteur", + "title": "Cat\u00e9gorie de l'acheteur", + "description": "Cat\u00e9gorie de l'acheteur selon son code juridique INSEE.", + "short_title": "Cat\u00e9gorie acheteur", "enum": [ "Commune", "Groupement de communes", - "Département", - "Département outre-mer", - "Région", - "État", - "Établissement hospitalier", + "D\u00e9partement", + "D\u00e9partement outre-mer", + "R\u00e9gion", + "\u00c9tat", + "\u00c9tablissement hospitalier", "EPIC", "Syndicat mixte" ] @@ -22,90 +22,90 @@ "name": "acheteur_commune_code", "type": "string", "title": "Commune de l'acheteur (code)", - "description": "Code de la commune où se trouve l'acheteur.", + "description": "Code de la commune o\u00f9 se trouve l'acheteur.", "short_title": "Commune ach. (code)" }, { "name": "acheteur_commune_nom", "type": "string", "title": "Commune de l'acheteur", - "description": "Nom de la commune où se trouve l'acheteur.", + "description": "Nom de la commune o\u00f9 se trouve l'acheteur.", "short_title": "Commune acheteur" }, { "name": "acheteur_departement_code", "type": "string", - "title": "Département de l'acheteur (code)", - "description": "Code du département où se trouve l'acheteur.", - "short_title": "Département ach. (code)" + "title": "D\u00e9partement de l'acheteur (code)", + "description": "Code du d\u00e9partement o\u00f9 se trouve l'acheteur.", + "short_title": "D\u00e9partement ach. (code)" }, { "name": "acheteur_departement_nom", "type": "string", - "title": "Département de l'acheteur", - "description": "Nom du département où se trouve l'acheteur.", - "short_title": "Département acheteur" + "title": "D\u00e9partement de l'acheteur", + "description": "Nom du d\u00e9partement o\u00f9 se trouve l'acheteur.", + "short_title": "D\u00e9partement acheteur" }, { "name": "acheteur_id", "type": "integer", "title": "SIRET acheteur", - "description": "Identifiant de l'établissement de l'acheteur (SIRET), référencé dans la base SIRENE de l'INSEE.", + "description": "Identifiant de l'\u00e9tablissement de l'acheteur (SIRET), r\u00e9f\u00e9renc\u00e9 dans la base SIRENE de l'INSEE.", "short_title": null }, { "name": "acheteur_latitude", "type": "number", "title": "Latitude de l'acheteur", - "description": "Latitude des coordonnées géographiques de l'acheteur.", + "description": "Latitude des coordonn\u00e9es g\u00e9ographiques de l'acheteur.", "short_title": "Latitude acheteur" }, { "name": "acheteur_longitude", "type": "number", "title": "Longitude de l'acheteur", - "description": "Longitude des coordonnées géographiques de l'acheteur.", + "description": "Longitude des coordonn\u00e9es g\u00e9ographiques de l'acheteur.", "short_title": "Longitude acheteur" }, { "name": "acheteur_nom", "type": "string", "title": "Nom acheteur", - "description": "Nom de l'acheteur tel que renseigné dans la base SIRENE de l'INSEE.", + "description": "Nom de l'acheteur tel que renseign\u00e9 dans la base SIRENE de l'INSEE.", "short_title": "Acheteur" }, { "name": "acheteur_region_code", "type": "string", - "title": "Région de l'acheteur (code)", - "description": "Code de la région où se trouve l'acheteur.", - "short_title": "Région ach. (code)" + "title": "R\u00e9gion de l'acheteur (code)", + "description": "Code de la r\u00e9gion o\u00f9 se trouve l'acheteur.", + "short_title": "R\u00e9gion ach. (code)" }, { "name": "acheteur_region_nom", "type": "string", - "title": "Région de l'acheteur", - "description": "Nom de la région où se trouve l'acheteur.", - "short_title": "Région acheteur" + "title": "R\u00e9gion de l'acheteur", + "description": "Nom de la r\u00e9gion o\u00f9 se trouve l'acheteur.", + "short_title": "R\u00e9gion acheteur" }, { "name": "attributionAvance", "type": "boolean", "title": "Attribution avance", - "description": "Si une avance sur le montant du marché public a été attribuée aux titulaires.", + "description": "Si une avance sur le montant du march\u00e9 public a \u00e9t\u00e9 attribu\u00e9e aux titulaires.", "short_title": null }, { "name": "ccag", "type": "string", "title": "CCAG", - "description": "Cahier des clauses administratives générales et techniques (CCAG) utilisé pour le marché public.", + "description": "Cahier des clauses administratives g\u00e9n\u00e9rales et techniques (CCAG) utilis\u00e9 pour le march\u00e9 public.", "short_title": null, "enum": [ "Travaux", - "Maitrise d'œuvre", + "Maitrise d'\u0153uvre", "Fournitures courantes et services", - "Marchés industriels", + "March\u00e9s industriels", "Prestations intellectuelles", "Techniques de l'information et de la communication" ] @@ -114,317 +114,321 @@ "name": "codeCPV", "type": "string", "title": "Code CPV", - "description": "Catégorie de bien, service ou travaux achetés, selon le Vocabulaire commun pour les marchés publics (CPV).", + "description": "Cat\u00e9gorie de bien, service ou travaux achet\u00e9s, selon le Vocabulaire commun pour les march\u00e9s publics (CPV).", "short_title": "CPV" }, { "name": "considerationsEnvironnementales", "type": "string", - "title": "Considérations environnementales", - "description": "Les considérations environnementales prévues dans le marché public.", + "title": "Consid\u00e9rations environnementales", + "description": "Les consid\u00e9rations environnementales pr\u00e9vues dans le march\u00e9 public.", "short_title": "Cons. environnementales", - "enum": ["Clause environnementale", "Critère environnemental"] + "enum": ["Clause environnementale", "Crit\u00e8re environnemental"] }, { "name": "considerationsSociales", "type": "string", - "title": "Considérations sociales", - "description": "Les considérations sociales prévues dans le marché public.", + "title": "Consid\u00e9rations sociales", + "description": "Les consid\u00e9rations sociales pr\u00e9vues dans le march\u00e9 public.", "short_title": "Cons. sociales", - "enum": ["Clause sociale", "Critère social", "Marché réservé"] + "enum": [ + "Clause sociale", + "Crit\u00e8re social", + "March\u00e9 r\u00e9serv\u00e9" + ] }, { "name": "dateNotification", "type": "date", "title": "Date notification", - "description": "Date à laquelle le marché public ou de la modification a été notifiée aux titulaires du marché public.", + "description": "Date \u00e0 laquelle le march\u00e9 public ou de la modification a \u00e9t\u00e9 notifi\u00e9e aux titulaires du march\u00e9 public.", "short_title": null, "format": "default" }, { "name": "datePublicationDonnees", "type": "date", - "title": "Date publication données", - "description": "Date à laquelle les données du marché public ou de la modification ont été publiées sur data.gouv.fr.", - "short_title": "Date pub. données", + "title": "Date publication donn\u00e9es", + "description": "Date \u00e0 laquelle les donn\u00e9es du march\u00e9 public ou de la modification ont \u00e9t\u00e9 publi\u00e9es sur data.gouv.fr.", + "short_title": "Date pub. donn\u00e9es", "format": "default" }, { "name": "donneesActuelles", "type": "boolean", - "title": "Données actuelles", - "description": "Si les données de cette ligne sont les données actuelles du marché public, une fois les éventuelles modifications prises en compte.", + "title": "Donn\u00e9es actuelles", + "description": "Si les donn\u00e9es de cette ligne sont les donn\u00e9es actuelles du march\u00e9 public, une fois les \u00e9ventuelles modifications prises en compte.", "short_title": null }, { "name": "dureeMois", "type": "integer", - "title": "Durée (mois)", - "description": "Durée en mois du marché attribué.", + "title": "Dur\u00e9e (mois)", + "description": "Dur\u00e9e en mois du march\u00e9 attribu\u00e9.", "short_title": null }, { "name": "dureeRestanteMois", "type": "number", - "title": "Durée restante (mois)", - "description": "Durée approximative en mois restante dans le marché, en tenant compte de la date de notification et de la durée du marché. Ce nombre ne peut être inférieur à 0.", + "title": "Dur\u00e9e restante (mois)", + "description": "Dur\u00e9e approximative en mois restante dans le march\u00e9, en tenant compte de la date de notification et de la dur\u00e9e du march\u00e9. Ce nombre ne peut \u00eatre inf\u00e9rieur \u00e0 0.", "short_title": null }, { "name": "formePrix", "type": "string", "title": "Forme prix", - "description": "La forme du prix du marché public. Unitaire, Forfaitaire ou Mixte.", + "description": "La forme du prix du march\u00e9 public. Unitaire, Forfaitaire ou Mixte.", "short_title": null }, { "name": "id", "type": "string", "title": "Identifiant interne", - "description": "Identifiant attribué par l'acheteur, censé être unique au sein de ses marchés.", + "description": "Identifiant attribu\u00e9 par l'acheteur, cens\u00e9 \u00eatre unique au sein de ses march\u00e9s.", "short_title": "Id. interne" }, { "name": "idAccordCadre", "type": "string", "title": "Identifiant accord-cadre", - "description": "Pour un marché subséquent, l'identifiant interne du marché public relevant de la technique d'achat accord-cadre auquel il est lié.", + "description": "Pour un march\u00e9 subs\u00e9quent, l'identifiant interne du march\u00e9 public relevant de la technique d'achat accord-cadre auquel il est li\u00e9.", "short_title": "Id. accord-cadre" }, { "name": "lieuExecution_code", "type": "integer", - "title": "Code lieu exécution", - "description": "Code du lieu d'exécution du marché public. Le type de code est renseigné par 'Type code lieu exécution'.", - "short_title": "Lieu exécution" + "title": "Code lieu ex\u00e9cution", + "description": "Code du lieu d'ex\u00e9cution du march\u00e9 public. Le type de code est renseign\u00e9 par 'Type code lieu ex\u00e9cution'.", + "short_title": "Lieu ex\u00e9cution" }, { "name": "lieuExecution_typeCode", "type": "string", - "title": "Type code lieu exécution", - "description": "Type du code du lieu d'exécution.", - "short_title": "Type lieu exécution", + "title": "Type code lieu ex\u00e9cution", + "description": "Type du code du lieu d'ex\u00e9cution.", + "short_title": "Type lieu ex\u00e9cution", "enum": [ "Code postal", "Code commune", "Code arrondissement", " Code canton", - "Code département", - "Code région", + "Code d\u00e9partement", + "Code r\u00e9gion", "Code pays" ] }, { "name": "marcheInnovant", "type": "boolean", - "title": "Marché innovant", - "description": "Si le marché comporte des travaux, services ou fournitures innovantes.", + "title": "March\u00e9 innovant", + "description": "Si le march\u00e9 comporte des travaux, services ou fournitures innovantes.", "short_title": null }, { "name": "modalitesExecution", "type": "string", - "title": "Modalités exécution", - "description": "Les modalités d'exécution du marché public.", + "title": "Modalit\u00e9s ex\u00e9cution", + "description": "Les modalit\u00e9s d'ex\u00e9cution du march\u00e9 public.", "short_title": null, - "enum": ["Tranches", "Bons de commande", "Marchés subséquents"] + "enum": ["Tranches", "Bons de commande", "March\u00e9s subs\u00e9quents"] }, { "name": "modification_id", "type": "integer", "title": "Identifiant modification", - "description": "Identifiant de la modification. 0 = données initiales du marché public, 1 = première modification, etc.", + "description": "Identifiant de la modification. 0 = donn\u00e9es initiales du march\u00e9 public, 1 = premi\u00e8re modification, etc.", "short_title": "Id. modification" }, { "name": "montant", "type": "number", - "title": "Montant attribué", - "description": "Montant forfaitaire ou montant maximum estimé hors-taxes, en euros. Ce montant est le montant attribué. Le montant final payé aux titulaires peut évoluer lors de la signature du contrat et de l'exécution du marché.", + "title": "Montant attribu\u00e9", + "description": "Montant forfaitaire ou montant maximum estim\u00e9 hors-taxes, en euros. Ce montant est le montant attribu\u00e9. Le montant final pay\u00e9 aux titulaires peut \u00e9voluer lors de la signature du contrat et de l'ex\u00e9cution du march\u00e9.", "short_title": "Montant" }, { "name": "nature", "type": "string", "title": "Nature", - "description": "Marché, Marché de partenariat ou Marché de sécurité.", + "description": "March\u00e9, March\u00e9 de partenariat ou March\u00e9 de s\u00e9curit\u00e9.", "short_title": null }, { "name": "objet", "type": "string", "title": "Objet", - "description": "Objet du marché public. Potentiellement coupé à 256 ou 1 000 caractères par le producteur de données.", + "description": "Objet du march\u00e9 public. Potentiellement coup\u00e9 \u00e0 256 ou 1 000 caract\u00e8res par le producteur de donn\u00e9es.", "short_title": null }, { "name": "offresRecues", "type": "integer", - "title": "Offres reçues", - "description": "Le nombre d'offres reçues pendant la phase d'appel d'offres. Comprend aussi les offres irrégulières, inacceptables, inappropriées et anormalement basses.", + "title": "Offres re\u00e7ues", + "description": "Le nombre d'offres re\u00e7ues pendant la phase d'appel d'offres. Comprend aussi les offres irr\u00e9guli\u00e8res, inacceptables, inappropri\u00e9es et anormalement basses.", "short_title": null }, { "name": "origineFrance", "type": "number", "title": "Origine France", - "description": "Pour les marchés de fournitures de denrées alimentaires, de véhicules, de produits de santé et d'habillement, selon la liste annexée à l'arrêté du 22 décembre 2022, la part des produits français avec laquelle le marché sera exécuté. 0.2 = 20 % de la part des produits sont français. Cette valeur ne peut pas être supérieure à la valeur de origineUE.", + "description": "Pour les march\u00e9s de fournitures de denr\u00e9es alimentaires, de v\u00e9hicules, de produits de sant\u00e9 et d'habillement, selon la liste annex\u00e9e \u00e0 l'arr\u00eat\u00e9 du 22 d\u00e9cembre 2022, la part des produits fran\u00e7ais avec laquelle le march\u00e9 sera ex\u00e9cut\u00e9. 0.2 = 20 % de la part des produits sont fran\u00e7ais. Cette valeur ne peut pas \u00eatre sup\u00e9rieure \u00e0 la valeur de origineUE.", "short_title": null }, { "name": "origineUE", "type": "number", "title": "Origine UE", - "description": "Pour les marchés de fournitures de denrées alimentaires, de véhicules, de produits de santé et d'habillement, selon la liste annexée à l'arrêté du 22 décembre 2022, la part des produits issus de l'Union européenne avec laquelle le marché sera exécuté. 0.2 = 20 % de la part des produits provient de l'Union européenne. Cette valeur ne peut pas être inférieure à la valeur de origineFrance.", + "description": "Pour les march\u00e9s de fournitures de denr\u00e9es alimentaires, de v\u00e9hicules, de produits de sant\u00e9 et d'habillement, selon la liste annex\u00e9e \u00e0 l'arr\u00eat\u00e9 du 22 d\u00e9cembre 2022, la part des produits issus de l'Union europ\u00e9enne avec laquelle le march\u00e9 sera ex\u00e9cut\u00e9. 0.2 = 20 % de la part des produits provient de l'Union europ\u00e9enne. Cette valeur ne peut pas \u00eatre inf\u00e9rieure \u00e0 la valeur de origineFrance.", "short_title": null }, { "name": "procedure", "type": "string", - "title": "Procédure", - "description": "Le type de procédure utilisé pour le marché public.", + "title": "Proc\u00e9dure", + "description": "Le type de proc\u00e9dure utilis\u00e9 pour le march\u00e9 public.", "short_title": null, "enum": [ - "Procédure négociée ouverte", - "Procédure non négociée ouverte", - "Procédure négociée restreinte", - "Procédure non négociée restreinte" + "Proc\u00e9dure n\u00e9goci\u00e9e ouverte", + "Proc\u00e9dure non n\u00e9goci\u00e9e ouverte", + "Proc\u00e9dure n\u00e9goci\u00e9e restreinte", + "Proc\u00e9dure non n\u00e9goci\u00e9e restreinte" ] }, { "name": "sourceDataset", "type": "string", "title": "Source dataset", - "description": "Code du jeu de données dont proviennent les données de ce marché public.", + "description": "Code du jeu de donn\u00e9es dont proviennent les donn\u00e9es de ce march\u00e9 public.", "short_title": null }, { "name": "sourceFile", "type": "string", "title": "Source fichier", - "description": "Lien vers le fichier de données ouvertes dont proviennent les données de ce marché public.", + "description": "Lien vers le fichier de donn\u00e9es ouvertes dont proviennent les donn\u00e9es de ce march\u00e9 public.", "short_title": null, "format": "uri" }, { "name": "sousTraitanceDeclaree", "type": "boolean", - "title": "Sous-traitance déclarée", - "description": "Au moment de la notification du marché, les titulaires du marché ont déclaré s'appuyer sur un ou plusieurs sous-traitants pour ce marché public.", + "title": "Sous-traitance d\u00e9clar\u00e9e", + "description": "Au moment de la notification du march\u00e9, les titulaires du march\u00e9 ont d\u00e9clar\u00e9 s'appuyer sur un ou plusieurs sous-traitants pour ce march\u00e9 public.", "short_title": "Sous-traitance" }, { "name": "tauxAvance", "type": "number", "title": "Taux avance", - "description": "Taux de l'avance attribuée au titulaire principal du marché public par rapport au montant du marché (O.1 = 10 % du montant du marché). En fonction de la valeur de attributionAvance, une valeur égale à 0 signifie qu'il y a une avance mais que le taux n'est pas connu (attributionAvance=true).", + "description": "Taux de l'avance attribu\u00e9e au titulaire principal du march\u00e9 public par rapport au montant du march\u00e9 (O.1 = 10 % du montant du march\u00e9). En fonction de la valeur de attributionAvance, une valeur \u00e9gale \u00e0 0 signifie qu'il y a une avance mais que le taux n'est pas connu (attributionAvance=true).", "short_title": null }, { "name": "techniques", "type": "string", "title": "Techniques", - "description": "Les techniques d'achat utilisées pour le marché public.", + "description": "Les techniques d'achat utilis\u00e9es pour le march\u00e9 public.", "short_title": null, "enum": [ "Accord-cadre", "Concours", - "Système de qualification", - "Système d'acquisition dynamique", - "Catalogue électronique", - "Enchère électronique" + "Syst\u00e8me de qualification", + "Syst\u00e8me d'acquisition dynamique", + "Catalogue \u00e9lectronique", + "Ench\u00e8re \u00e9lectronique" ] }, { "name": "titulaire_categorie", "type": "string", - "title": "Catégorie du titulaire", - "description": "Catégorie de l'entreprise titulaire selon la classification de l'INSEE.", - "short_title": "Catégorie titulaire", + "title": "Cat\u00e9gorie du titulaire", + "description": "Cat\u00e9gorie de l'entreprise titulaire selon la classification de l'INSEE.", + "short_title": "Cat\u00e9gorie titulaire", "enum": ["PME", "ETI", "GE"] }, { "name": "titulaire_commune_code", "type": "string", "title": "Commune du titulaire (code)", - "description": "Code de la commune où se trouve le titulaire.", + "description": "Code de la commune o\u00f9 se trouve le titulaire.", "short_title": "Commune tit. (code)" }, { "name": "titulaire_commune_nom", "type": "string", "title": "Commune du titulaire", - "description": "Nom de la commune où se trouve le titulaire.", + "description": "Nom de la commune o\u00f9 se trouve le titulaire.", "short_title": "Commune titulaire" }, { "name": "titulaire_departement_code", "type": "string", - "title": "Département du titulaire (code)", - "description": "Code du département où se trouve le titulaire.", - "short_title": "Département tit. (code)" + "title": "D\u00e9partement du titulaire (code)", + "description": "Code du d\u00e9partement o\u00f9 se trouve le titulaire.", + "short_title": "D\u00e9partement tit. (code)" }, { "name": "titulaire_departement_nom", "type": "string", - "title": "Département du titulaire", - "description": "Nom du département où se trouve le titulaire.", - "short_title": "Département titulaire" + "title": "D\u00e9partement du titulaire", + "description": "Nom du d\u00e9partement o\u00f9 se trouve le titulaire.", + "short_title": "D\u00e9partement titulaire" }, { "name": "titulaire_distance", "type": "integer", "title": "Distance acheteur-titulaire", - "description": "Distance en kilomètres entre l'adresse de l'acheteur et celle du titulaire.", + "description": "Distance en kilom\u00e8tres entre l'adresse de l'acheteur et celle du titulaire.", "short_title": "Distance" }, { "name": "titulaire_id", "type": "integer", "title": "Identifiant titulaire", - "description": "Identifiant du titulaire du marché. Voir 'Type identifiant' pour le référentiel utilisé", + "description": "Identifiant du titulaire du march\u00e9. Voir 'Type identifiant' pour le r\u00e9f\u00e9rentiel utilis\u00e9", "short_title": "Id. titulaire" }, { "name": "titulaire_latitude", "type": "number", "title": "Latitude du titulaire", - "description": "Latitude des coordonnées géographiques du titulaire.", + "description": "Latitude des coordonn\u00e9es g\u00e9ographiques du titulaire.", "short_title": "Latitude titulaire" }, { "name": "titulaire_longitude", "type": "number", "title": "Longitude du titulaire", - "description": "Longitude des coordonnées géographiques du titulaire.", + "description": "Longitude des coordonn\u00e9es g\u00e9ographiques du titulaire.", "short_title": "Longitude titulaire" }, { "name": "titulaire_nom", "type": "string", "title": "Nom titulaire", - "description": "Nom du titulaire. Nom tel que renseigné dans la base SIRENE de l'INSEE si c'est un SIRET.", + "description": "Nom du titulaire. Nom tel que renseign\u00e9 dans la base SIRENE de l'INSEE si c'est un SIRET.", "short_title": "Titulaire" }, { "name": "titulaire_region_code", "type": "string", - "title": "Région du titulaire (code)", - "description": "Code de la région où se trouve le titulaire.", - "short_title": "Région tit. (code)" + "title": "R\u00e9gion du titulaire (code)", + "description": "Code de la r\u00e9gion o\u00f9 se trouve le titulaire.", + "short_title": "R\u00e9gion tit. (code)" }, { "name": "titulaire_region_nom", "type": "string", - "title": "Région du titulaire", - "description": "Nom de la région où se trouve le titulaire.", - "short_title": "Région titulaire" + "title": "R\u00e9gion du titulaire", + "description": "Nom de la r\u00e9gion o\u00f9 se trouve le titulaire.", + "short_title": "R\u00e9gion titulaire" }, { "name": "titulaire_typeIdentifiant", "type": "string", "title": "Type identifiant", - "description": "Référentiel utilisé pour l'identifiant du titulaire.", + "description": "R\u00e9f\u00e9rentiel utilis\u00e9 pour l'identifiant du titulaire.", "short_title": "Type id.", "enum": ["SIRET", "TVA", "TAHITI", "RIDET", "FRWF", "IREP", "HORS-UE"] }, @@ -432,7 +436,7 @@ "name": "type", "type": "string", "title": "Type", - "description": "Type de marché public : fournitures, services ou travaux (dérivé du code CPV).", + "description": "Type de march\u00e9 public : fournitures, services ou travaux (d\u00e9riv\u00e9 du code CPV).", "short_title": "Type", "enum": ["Fournitures", "Services", "Travaux"] }, @@ -440,7 +444,7 @@ "name": "typeGroupementOperateurs", "type": "string", "title": "Type groupement", - "description": "Le type de groupement d'entreprises ou d'opérateurs économiques.", + "description": "Le type de groupement d'entreprises ou d'op\u00e9rateurs \u00e9conomiques.", "short_title": "Groupement", "enum": ["Conjoint", "Solidaire"] }, @@ -448,12 +452,12 @@ "name": "typesPrix", "type": "string", "title": "Types prix", - "description": "Les types de prix du marché public.", + "description": "Les types de prix du march\u00e9 public.", "short_title": null, "enum": [ - "Définitif ferme", - "Définitif actualisable", - "Définitif révisable", + "D\u00e9finitif ferme", + "D\u00e9finitif actualisable", + "D\u00e9finitif r\u00e9visable", "Provisoire" ] }, @@ -461,7 +465,7 @@ "name": "uid", "type": "string", "title": "Identifiant unique", - "description": "Concaténation du SIRET de l'acheteur (acheteur_id) et de l'identifiant interne de l'acheteur (id). Utilisé comme identifiant de marché unique au niveau national.", + "description": "Concat\u00e9nation du SIRET de l'acheteur (acheteur_id) et de l'identifiant interne de l'acheteur (id). Utilis\u00e9 comme identifiant de march\u00e9 unique au niveau national.", "short_title": "Id. unique" } ] From f9aefb55d8c1feb37b029506684fd5b81d268b63 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 23 Jun 2026 22:32:12 +0200 Subject: [PATCH 150/151] =?UTF-8?q?feat(titulaire):=20afficher=20le=20libe?= =?UTF-8?q?ll=C3=A9=20d'activit=C3=A9=20NAF=20en=20sous-titre=20gris?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/pages/titulaire.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index e30022d..1f5e540 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -87,13 +87,19 @@ layout = [ className="mb-2", children=[ dbc.Col( - html.H2( - children=[ - html.Span(id="titulaire_siret"), - " - ", - html.Span(id="titulaire_nom"), - ], - ), + [ + html.H2( + children=[ + html.Span(id="titulaire_siret"), + " - ", + html.Span(id="titulaire_nom"), + ], + ), + html.P( + id="titulaire_activite_libelle", + style={"color": "gray", "marginTop": "-10px"}, + ), + ], width=8, ), dbc.Col( @@ -255,10 +261,20 @@ layout = [ Output(component_id="titulaire_departement", component_property="children"), Output(component_id="titulaire_region", component_property="children"), Output(component_id="titulaire_lien_annuaire", component_property="href"), + Output(component_id="titulaire_activite_libelle", component_property="children"), Input(component_id="titulaire_url", component_property="pathname"), ) def update_titulaire_infos(url): titulaire_siret = url.split("/")[-1] + if "titulaire_activite_libelle" in DF_TITULAIRES.columns: + activite_libelle_row = DF_TITULAIRES.filter( + pl.col("titulaire_id") == titulaire_siret + ).select("titulaire_activite_libelle") + activite_libelle = ( + activite_libelle_row.item(0, 0) if activite_libelle_row.height > 0 else "" + ) + else: + activite_libelle = "" data = get_annuaire_data(titulaire_siret) data_etablissement = data.get("matching_etablissements") if data else None if data_etablissement: @@ -302,6 +318,7 @@ def update_titulaire_infos(url): departement, nom_region, lien_annuaire, + activite_libelle, ) From a6f60ce3c412152bdd60d79f293ab142d747da83 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 24 Jun 2026 02:56:42 +0200 Subject: [PATCH 151/151] =?UTF-8?q?Cache=20le=20champ=20de=20filtrage=20po?= =?UTF-8?q?ur=20la=20colonne=20'March=C3=A9'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index ef9674c..42afb2a 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -386,6 +386,10 @@ td[data-dash-column="marche"] a { text-decoration: none; } +th[data-dash-column="marche"].dash-filter input { + display: none; +} + /* Barre de défilement horizontale, collée en haut du tableau */ .marches_table .dt-hscroll { position: sticky;