From fef6c5a525025fdab3071c5fc254519138e11c7c Mon Sep 17 00:00:00 2001 From: fnecas Date: Fri, 14 Aug 2026 16:07:40 +0200 Subject: [PATCH 01/24] feat(ingestion): add ogr2ogr support for ingesting geospatial files into PostGIS --- docker/Dockerfile.airflow | 9 ++ libs/data_manipulation/pyproject.toml | 1 - .../src/data_manipulation/ingestion.py | 94 ++++++++++++++----- 3 files changed, 77 insertions(+), 27 deletions(-) diff --git a/docker/Dockerfile.airflow b/docker/Dockerfile.airflow index 4a84a5f1..6affe9a7 100644 --- a/docker/Dockerfile.airflow +++ b/docker/Dockerfile.airflow @@ -72,6 +72,15 @@ FROM base AS development COPY --from=builder /tmp/requirements.txt /tmp/requirements.txt RUN uv pip install --system -r /tmp/requirements.txt \ && rm -f /tmp/requirements.txt + +# Runtime deps nécessaires pour GDAL +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update \ + && apt-get install -y --no-install-recommends \ + gdal-bin libgdal-dev \ + && rm -rf /var/lib/apt/lists/* + USER airflow # ============================================================================ diff --git a/libs/data_manipulation/pyproject.toml b/libs/data_manipulation/pyproject.toml index 5a127a29..1711aff3 100644 --- a/libs/data_manipulation/pyproject.toml +++ b/libs/data_manipulation/pyproject.toml @@ -14,7 +14,6 @@ description = "Add your description here" readme = "README.md" requires-python = "==3.12.*" -# To allow hot reload in local development, please replicate below dependencies in Dockerfile.airflow inside airflow-dev image dependencies = [ "chardet==7.4.3", "geoalchemy2==0.19.0", diff --git a/libs/data_manipulation/src/data_manipulation/ingestion.py b/libs/data_manipulation/src/data_manipulation/ingestion.py index 8ce35f81..d1e743e6 100644 --- a/libs/data_manipulation/src/data_manipulation/ingestion.py +++ b/libs/data_manipulation/src/data_manipulation/ingestion.py @@ -1,6 +1,8 @@ import logging import os import re +import subprocess +import time import tempfile import xml.etree.ElementTree as ET import zipfile @@ -111,9 +113,7 @@ def _read_file_encoded(file_path: str, i: int = 0) -> gpd.GeoDataFrame | pd.Data Returns: GeoDataFrame or DataFrame with the chunk data (empty when there is no more data) """ - rows = slice(i * CHUNK_SIZE, i * CHUNK_SIZE + CHUNK_SIZE, None) - # Parquet is columnar and not row-sliceable cheaply: read it fully on the first - # chunk and signal completion afterwards to avoid re-reading / duplicating rows. + logger.info("Use standard method") if Path(file_path).suffix.lower() in (".parquet", ".geoparquet"): ds = pq.ParquetDataset(file_path) if i >= len(ds.fragments): @@ -283,6 +283,60 @@ def _get_geo_column_from_table(table: Table) -> str | None: return None +def ingest_file_with_ogr2ogr( + file_path: str, + table_name: str, + engine: Engine, + schema: str = DEFAULT_SCHEMA, +) -> None: + """Ingest a geospatial file into a PostGIS table using ogr2ogr. + + Args: + file_path: Path to the local file to ingest + table_name: Target table name in PostGIS + engine: SQLAlchemy engine for the target PostGIS database + schema: Target schema (default: public) + """ + validate_table_name(table_name, max_length=POSTGIS_TABLE_NAME_MAX_LENGTH) + validate_schema_name(schema) + + url = engine.url + pg_conn_parts = [ + f"host={url.host}", + f"port={url.port or 5432}", + f"dbname={url.database}", + f"user={url.username}", + f"password={url.password}", + ] + pg_connection = "PG:" + " ".join(part for part in pg_conn_parts if part.split("=", 1)[1]) + + command = [ + "ogr2ogr", + "-f", + "PostgreSQL", + pg_connection, + file_path, + "-nln", + f"{schema}.{table_name}", + "-overwrite", + "-lco", + f"GEOMETRY_NAME={DEFAULT_GEOMETRY_COLUMN}", + "-lco", + f"SCHEMA={schema}", + ] + + logger.info(f"Running ogr2ogr to ingest {file_path} into {schema}.{table_name}") + + try: + # -------- + # WARNING: don't log the command as the PG connection string contains credentials + # -------- + subprocess.run(command, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as e: + logger.error(f"ogr2ogr failed ingesting {file_path}: {e.stderr}") + raise Exception(f"ogr2ogr failed: {e.stderr}") + + def ingest_data_from_url_into_postgis( url: str, table_name: str, @@ -335,29 +389,17 @@ def ingest_data_from_url_into_postgis( with open(temp_file_path, "wb") as temp_file: temp_file.write(content) - i = 0 - while True: - data = _read_file_encoded(str(temp_file_path), i) - if data.empty: - break - write_data_to_postgis( - data, - table_name, - engine, - schema, - if_exists="replace" if i == 0 else "append", - ) - logger.debug( - "Ingested chunk %s (%s rows) from URL %s into table %s", - i, - len(data), - url, - table_name, - ) - # A short read means the file is exhausted — avoid an extra empty read. - if len(data) < CHUNK_SIZE: - break - i += 1 + + start = time.time() + + ingest_file_with_ogr2ogr(str(temp_file_path), table_name, engine, schema) + + # Calculate the end time and time taken + end = time.time() + length = end - start + + print("It took", length, "seconds.") + except Exception as e: logger.error(f"Error ingesting data from URL {url}: {e}") raise From 682793c7457c6d1c1797e7b7e04ae9f8f6b517be Mon Sep 17 00:00:00 2001 From: fnecas Date: Fri, 14 Aug 2026 16:10:28 +0200 Subject: [PATCH 02/24] feat(transformation): implement SQL-native transformation pipeline for PostGIS ingestion --- ARCHITECTURE.md | 4 +- .../src/api/routes/ingestion/staging.py | 77 +- apps/elt/dags/task_groups/ingestion.py | 24 + apps/elt/dags/task_groups/transformation.py | 66 +- apps/elt/dags/utils.py | 26 +- libs/data_manipulation/README.md | 29 +- libs/data_manipulation/pyproject.toml | 3 +- .../src/data_manipulation/__init__.py | 33 +- .../src/data_manipulation/ingestion.py | 568 ++------ .../transformation/filter_sql.py | 50 +- .../transformation/sql_transform.py | 502 +++++++ .../transformation/transform.py | 178 --- .../transformation/transform_columns.py | 148 -- .../transformation/transform_encoding.py | 30 - .../transformation/transform_geom_point.py | 56 - .../transformation/transform_projection.py | 28 - .../src/data_manipulation/utils.py | 8 - .../tests/test_column_actions.py | 724 ++-------- .../data_manipulation/tests/test_ingestion.py | 1263 ++--------------- .../tests/test_transformation.py | 334 +++-- uv.lock | 140 +- 21 files changed, 1151 insertions(+), 3140 deletions(-) create mode 100644 libs/data_manipulation/src/data_manipulation/transformation/sql_transform.py delete mode 100644 libs/data_manipulation/src/data_manipulation/transformation/transform.py delete mode 100644 libs/data_manipulation/src/data_manipulation/transformation/transform_columns.py delete mode 100644 libs/data_manipulation/src/data_manipulation/transformation/transform_encoding.py delete mode 100644 libs/data_manipulation/src/data_manipulation/transformation/transform_geom_point.py delete mode 100644 libs/data_manipulation/src/data_manipulation/transformation/transform_projection.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f5530b26..ec17190c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -47,8 +47,8 @@ Supported source types: `FILE`, `URL`, `FTP`, `DATABASE`, `API` (WFS / OGC API F ### Shared Library (`libs/data_manipulation/`) Python package imported by both the backend and the ELT DAGs. Contains: -- Source-specific ingestion functions (file, URL, FTP, database, OGC services) -- Transformation pipeline (column remapping, projection reprojection, geometry handling, SQL filters) +- Source-specific ingestion functions that stream into PostGIS via `ogr2ogr`/GDAL (file, URL, FTP, database, OGC services) +- SQL-native transformation pipeline (column remapping, projection, geometry handling, filters) executed server-side — data never leaves PostgreSQL except a bounded preview - GeoServer write helpers - Shared models (`IntegrityTransformation`, column config, etc.) diff --git a/apps/backend/src/api/routes/ingestion/staging.py b/apps/backend/src/api/routes/ingestion/staging.py index 3664eea8..261e1003 100644 --- a/apps/backend/src/api/routes/ingestion/staging.py +++ b/apps/backend/src/api/routes/ingestion/staging.py @@ -1,28 +1,24 @@ -import json import re from datetime import date, datetime, timezone from typing import Any, Optional from urllib.parse import urlparse from uuid import UUID, uuid4 -import geopandas as gpd -import pandas as pd import requests from airflow_client.client.models.dag_run_patch_body import DAGRunPatchBody from data_manipulation import ( IntegrityTransformation, detect_column_type_from_sqla, - read_and_transform_data, + detect_table_srid, + read_transformed_preview, ) from data_manipulation.constants import DB_URI_PREFIX from data_manipulation.database import schema_exists, table_exists -from data_manipulation.ingestion import read_data_from_postgis from data_manipulation.logging import configure_logging from data_manipulation.models import ForceProjection as DataManipulationForceProjection from data_manipulation.utils import sanitize_name from data_manipulation.validators import validate_schema_name, validate_table_name from fastapi import APIRouter, Body, File, Form, Header, HTTPException, Query, UploadFile -from shapely.geometry.base import BaseGeometry from sqlalchemy import MetaData, Table, func, select from sqlalchemy.orm.attributes import flag_modified @@ -821,9 +817,7 @@ def _detect_original_projection( ) -> str | None: """Return the CRS string if the staging table contains geographic data.""" try: - sample = read_data_from_postgis(staging_table_name, engine, schema, limit=1) - if isinstance(sample, gpd.GeoDataFrame) and sample.crs is not None: - return sample.crs.to_string() + return detect_table_srid(staging_table_name, engine, schema) except Exception as e: logger.warning(f"Could not detect original projection: {e}") return None @@ -1110,7 +1104,7 @@ def get_staging_preview( config = IntegrityTransformation.model_validate(integrity_link.integrity_transformation) except Exception as e: logger.warning(f"Could not deserialize transformation config, using raw: {e}") - # SECURITY NOTE: when raw=True, config remains None and read_and_transform_data + # SECURITY NOTE: when raw=True, config remains None and read_transformed_preview # returns ALL columns including those marked as excluded in the saved config. # This is intentional — raw mode is a debug/fallback path used when the # transformation itself causes a preview error. If excluded columns contain @@ -1149,60 +1143,13 @@ def get_staging_preview( ) try: - transformed_data = read_and_transform_data( - staging_table_name, engine, schema, config, limit=limit + preview = read_transformed_preview( + staging_table_name, engine, config, schema=schema, limit=limit ) - # Convert non-JSON-serializable temporal types to string (datetime, date, Timestamp). - _stringify_temporal_columns(transformed_data) - - data: list[dict[str, Any]] = [] - geojson_data = None - is_geographic = False - - # Convert geometry to WKT for tabular display if GeoDataFrame - if isinstance(transformed_data, gpd.GeoDataFrame): - is_geographic = True - - geometry_cols: list[str] = [] - for col in transformed_data.columns: # type: ignore[misc] - if not transformed_data[col].empty: # type: ignore[misc] - sample_item = transformed_data[col].iloc[0] - sample: Any = sample_item # type: ignore[misc] - - if isinstance(sample, BaseGeometry): - geometry_cols.append(col) # type: ignore[misc] - elif hasattr(sample_item, "wkt"): # type: ignore[misc] - geometry_cols.append(col) # type: ignore[misc] - - logger.info(f"Found geometry columns: {geometry_cols}") - - # Create GeoJSON for map display first, force to EPSG:4326 - map_gdf = transformed_data.copy() - - try: - if map_gdf.crs and map_gdf.crs.to_string() != "EPSG:4326": - map_gdf = map_gdf.to_crs("EPSG:4326") - logger.info(f"Reprojected data from {map_gdf.crs} to EPSG:4326 for map display") - except Exception as crs_error: - logger.warning(f"Could not reproject to EPSG:4326: {crs_error}") - - # Modify transformed_data directly for tabular display - if "geom" in geometry_cols: - transformed_data["geom"] = transformed_data["geom"].apply( # type: ignore[misc] - lambda geom: geom.wkt if geom is not None else None # type: ignore[misc] - ) - geometry_cols.remove("geom") - - # Drop extra geometry columns for tabular data - table_data = transformed_data.drop(columns=geometry_cols, errors="ignore") - data = table_data.to_dict(orient="records") # type: ignore[misc] - - geojson_str = map_gdf.to_json() # type: ignore[misc] - geojson_data = json.loads(geojson_str) if geojson_str else None - else: - # Regular DataFrame, no geometry conversion needed - data = transformed_data.to_dict(orient="records") # type: ignore[misc] + data = preview.rows + geojson_data = preview.geojson + is_geographic = preview.is_geographic # If the geom column was excluded in the saved config, suppress map data # regardless of include_excluded. Raw mode bypasses this rule. @@ -1210,10 +1157,8 @@ def get_staging_preview( is_geographic = False geojson_data = None - return StagingPreviewResponse( - data=data, # type: ignore[misc] - geojson=geojson_data, - is_geographic=is_geographic, + return StagingPreviewResponse.model_validate( + {"data": data, "geojson": geojson_data, "is_geographic": is_geographic} ) except Exception as e: diff --git a/apps/elt/dags/task_groups/ingestion.py b/apps/elt/dags/task_groups/ingestion.py index 2db6fded..10d3f131 100644 --- a/apps/elt/dags/task_groups/ingestion.py +++ b/apps/elt/dags/task_groups/ingestion.py @@ -273,6 +273,29 @@ def api_ingest_step(**context: dict[str, Any]) -> None: if not source_layer: raise AirflowException("source_layer is required for API import") + # Decrypt Basic Auth credentials if provided (e.g. protected WFS/OAPIF services) + auth = None + encrypted_credentials = params.get("encrypted_credentials") + if encrypted_credentials: + try: + encryption_key = Variable.get("datafeeder_encryption_key", default=None) + if not encryption_key: + raise AirflowException( + "Encryption key not found in Airflow Variables under 'datafeeder_encryption_key'" + ) + + datafeeder_engine = get_datafeeder_sql_engine() + + with datafeeder_engine.connect() as conn: + username, password = decrypt_credentials( + conn, encrypted_credentials, encryption_key + ) + auth = (username, password) + logger.info("Successfully decrypted Basic Auth credentials") + except Exception as e: + logger.error(f"Failed to decrypt Basic Auth credentials: {e}") + raise AirflowException(f"Failed to decrypt credentials: {e}") + engine = get_data_sql_engine() try: ingest_data_from_ogc_service_into_postgis( @@ -282,6 +305,7 @@ def api_ingest_step(**context: dict[str, Any]) -> None: target_table_name, engine, schema=get_staging_schema(), + auth=auth, ) except Exception as e: raise AirflowException(f"Failed to ingest data from OGC service: {e}") diff --git a/apps/elt/dags/task_groups/transformation.py b/apps/elt/dags/task_groups/transformation.py index 4f827718..04e1135e 100644 --- a/apps/elt/dags/task_groups/transformation.py +++ b/apps/elt/dags/task_groups/transformation.py @@ -9,8 +9,7 @@ from data_manipulation import ( CHUNK_SIZE, IntegrityTransformation, - read_and_transform_data, - write_data_to_postgis, + transform_staging_to_final, ) from data_manipulation.database import create_schema from sqlalchemy import MetaData, Table @@ -86,6 +85,10 @@ def read_transform_write_task(**context: dict[str, Any]) -> None: staging_schema = get_staging_schema() try: + logger.info( + f"Reading and transforming data from {staging_schema}.{staging_table_name}" + ) + # If this is a re-run (has last_retrieval_timestamp), drop the old final table first last_retrieval_timestamp = params.get("last_retrieval_timestamp") if last_retrieval_timestamp: @@ -109,55 +112,30 @@ def read_transform_write_task(**context: dict[str, Any]) -> None: ) create_schema(engine, final_schema) - logger.info( - f"Reading, transforming and writing data from " - f"{staging_schema}.{staging_table_name} to {final_schema}.{final_table_name}" + f"Transforming {staging_schema}.{staging_table_name} " + f"into {final_schema}.{final_table_name}" + ) + # Transformation runs entirely in PostGIS (CREATE TABLE AS) — no + # data is loaded into Python memory. + row_count = transform_staging_to_final( + staging_table=staging_table_name, + final_table=final_table_name, + engine=engine, + config=transformation_config, + staging_schema=staging_schema, + final_schema=final_schema, + create_id=True, ) - # Read, transform and write one chunk at a time to keep the memory footprint - # low for large tables (mirrors the chunked ingestion in ingestion.py). - i = 0 - total_rows = 0 - while True: - transformed_data = read_and_transform_data( - table_name=staging_table_name, - engine=engine, - schema=staging_schema, - config=transformation_config, - limit=CHUNK_SIZE, - offset=i * CHUNK_SIZE, - ) - if transformed_data.empty: - break - - chunk_len = len(transformed_data) - write_data_to_postgis( - data=transformed_data, - table_name=final_table_name, - engine=engine, - schema=final_schema, - # The UUID primary key is created once on the first chunk; subsequent - # appended rows receive their id_datafeeder from the column default. - create_id=i == 0, - if_exists="replace" if i == 0 else "append", - ) - total_rows += chunk_len - logger.info( - f"Transformed and wrote chunk {i} ({chunk_len} rows) to final table" - ) - - # A short read means the staging table is exhausted — avoid an extra empty query. - if chunk_len < CHUNK_SIZE: - break - i += 1 - - if total_rows == 0: + if row_count == 0: logger.error("No data to write after transformation.") raise AirflowException("No data to write after transformation.") - logger.info(f"Successfully wrote {total_rows} rows to final table") + logger.info(f"Successfully wrote {row_count} rows to final table") + except AirflowException: + raise except Exception as e: raise AirflowException(f"Failed to transform and load data: {e}") diff --git a/apps/elt/dags/utils.py b/apps/elt/dags/utils.py index 312de01c..2a012271 100644 --- a/apps/elt/dags/utils.py +++ b/apps/elt/dags/utils.py @@ -1,8 +1,7 @@ import os from datetime import timedelta -from typing import TypeVar +from typing import Any, TypeVar -import pandas as pd from airflow.providers.postgres.hooks.postgres import PostgresHook from airflow.sdk import Variable from sqlalchemy.engine import Engine @@ -60,9 +59,8 @@ def get_staging_timeout() -> timedelta: def normalize_nan(value: T | None, default: T) -> T: """Normalize NA/NaN/None values to a default. - pandas.DataFrame.to_dict() converts SQL NULL to float('nan') / numpy.nan, - which are truthy in Python. This breaks the common `value or default` pattern. - Use this function to properly handle NA/NaN values from pandas DataFrames. + Database NULLs read through a raw cursor come back as ``None``; this helper + also defends against float ``NaN`` without depending on pandas. Args: value: The value to check (can be None, NaN, or any valid value) @@ -71,6 +69,22 @@ def normalize_nan(value: T | None, default: T) -> T: Returns: The original value if it's not NA/NaN/None, otherwise the default """ - if value is None or pd.isna(value): + if value is None: + return default + if isinstance(value, float) and value != value: return default return value + + +def get_records_as_dicts(sql: str) -> list[dict[str, Any]]: + """Run *sql* on the Datafeeder database and return rows as dicts. + + Replaces the former ``PostgresHook.get_pandas_df(...).to_dict()`` so no + pandas dependency is required. SQL NULLs are returned as ``None``. + """ + hook = get_datafeeder_pg_hook() + conn = hook.get_conn() + with conn.cursor() as cursor: + cursor.execute(sql) + columns = [desc[0] for desc in cursor.description] + return [dict(zip(columns, row)) for row in cursor.fetchall()] diff --git a/libs/data_manipulation/README.md b/libs/data_manipulation/README.md index bf6dea55..341849b7 100644 --- a/libs/data_manipulation/README.md +++ b/libs/data_manipulation/README.md @@ -2,23 +2,34 @@ Shared Python library used by the Datafeeder backend and the Airflow ELT DAGs. -It centralizes the logic that reads, validates, transforms, and loads geospatial datasets so that the same code path is exercised whether a dataset is being previewed in the API or processed by a DAG. +It centralizes the logic that ingests, validates, transforms, and publishes geospatial datasets so that the same code path is exercised whether a dataset is being previewed in the API or processed by a DAG. + +Data is streamed **directly into PostGIS** with `ogr2ogr` (GDAL) and every transformation (rename, cast, reproject, geometry build, filter) is expressed as **parameterized SQL** executed server-side. Datasets never leave the database except for a small bounded preview — there is no in-memory geopandas/pandas layer. ## Layout ``` src/data_manipulation/ - ingestion.py # Read a source (WFS, CSV, SHP, GeoJSON, …) into a (Geo)DataFrame - transformation/ # Column rename / drop, type casting, filtering, reprojection - type_detection.py # Infer column types from raw data - validators.py # Schema and value validation - database.py # Helpers to load DataFrames into PostgreSQL / PostGIS - geoserver.py # GeoServer publication helpers - encryption.py # Secret handling for source credentials - models.py # Shared dataclasses / Pydantic models + ingestion.py # Stream a source (WFS/OAPIF, CSV, SHP, GeoJSON, Parquet, DB, …) into PostGIS via ogr2ogr + transformation/ + sql_transform.py # Build the canonical SQL SELECT; CTAS to final table + bounded preview + filter_sql.py # SQL column selection / ILIKE filters + type_detection.py # Infer column types from the database schema + validators.py # Schema and value validation + database.py # PostgreSQL / PostGIS helpers + geoserver.py # GeoServer publication helpers + encryption.py # Secret handling for source credentials + models.py # Shared dataclasses / Pydantic models constants.py / utils.py / logging.py ``` +## Requirements + +Ingestion shells out to `ogr2ogr`, so the runtime image must ship **GDAL** (with the +Parquet/Arrow driver for `.parquet`/`.geoparquet` support — GDAL ≥ 3.5). The Airflow and +backend Docker images provide it; it is not required to import the library or run the unit +tests (which mock the `ogr2ogr` subprocess). + ## Install The library is a uv workspace member; from the repository root: diff --git a/libs/data_manipulation/pyproject.toml b/libs/data_manipulation/pyproject.toml index 1711aff3..a5ce2f52 100644 --- a/libs/data_manipulation/pyproject.toml +++ b/libs/data_manipulation/pyproject.toml @@ -17,10 +17,9 @@ requires-python = "==3.12.*" dependencies = [ "chardet==7.4.3", "geoalchemy2==0.19.0", - "geopandas==1.1.3", "geoservercloud", - "pyarrow==24.0.0", "pydantic==2.13.4", + "pyproj==3.7.2", "sqlalchemy==2.0.49", ] diff --git a/libs/data_manipulation/src/data_manipulation/__init__.py b/libs/data_manipulation/src/data_manipulation/__init__.py index 1513035e..ed5aa96e 100644 --- a/libs/data_manipulation/src/data_manipulation/__init__.py +++ b/libs/data_manipulation/src/data_manipulation/__init__.py @@ -1,11 +1,10 @@ from data_manipulation.ingestion import ( - CHUNK_SIZE, + ingest_data_from_database_into_postgis, ingest_data_from_file_into_postgis, + ingest_data_from_ftp_into_postgis, ingest_data_from_ogc_service_into_postgis, ingest_data_from_url_into_postgis, - read_and_transform_data, - read_data_from_postgis, - write_data_to_postgis, + ingest_file_with_ogr2ogr, ) from data_manipulation.logging import configure_logging from data_manipulation.models import ( @@ -16,22 +15,34 @@ ForceProjection, IntegrityTransformation, ) -from data_manipulation.transformation.filter_sql import build_sql_column_ops -from data_manipulation.transformation.transform import apply_transformations +from data_manipulation.transformation.filter_sql import build_filter_clause, build_sql_column_ops +from data_manipulation.transformation.sql_transform import ( + PreviewResult, + TransformationQuery, + build_transformation_select, + detect_table_srid, + read_transformed_preview, + transform_staging_to_final, +) from data_manipulation.type_detection import detect_column_type_from_sqla __all__ = [ "hello", - "CHUNK_SIZE", + "ingest_data_from_database_into_postgis", "ingest_data_from_file_into_postgis", + "ingest_data_from_ftp_into_postgis", "ingest_data_from_ogc_service_into_postgis", "ingest_data_from_url_into_postgis", - "read_data_from_postgis", - "read_and_transform_data", - "apply_transformations", + "ingest_file_with_ogr2ogr", "build_sql_column_ops", + "build_filter_clause", + "build_transformation_select", + "transform_staging_to_final", + "read_transformed_preview", + "detect_table_srid", + "PreviewResult", + "TransformationQuery", "detect_column_type_from_sqla", - "write_data_to_postgis", "configure_logging", "CastType", "ColumnConfig", diff --git a/libs/data_manipulation/src/data_manipulation/ingestion.py b/libs/data_manipulation/src/data_manipulation/ingestion.py index d1e743e6..eb2bea15 100644 --- a/libs/data_manipulation/src/data_manipulation/ingestion.py +++ b/libs/data_manipulation/src/data_manipulation/ingestion.py @@ -2,30 +2,18 @@ import os import re import subprocess -import time import tempfile -import xml.etree.ElementTree as ET -import zipfile +import time from pathlib import Path from typing import Literal from urllib.error import URLError from urllib.parse import quote, unquote, urlencode, urlparse, urlunparse from urllib.request import urlretrieve -import chardet -import geopandas as gpd -import pandas as pd -import pyarrow.parquet as pq import requests -from geoalchemy2 import Geometry -from pyarrow.lib import ArrowException -from sqlalchemy import MetaData, Table, func, select, text from sqlalchemy.engine import Engine from data_manipulation.constants import DEFAULT_GEOMETRY_COLUMN, POSTGIS_TABLE_NAME_MAX_LENGTH -from data_manipulation.models import ColumnConfig, IntegrityTransformation -from data_manipulation.transformation.filter_sql import build_sql_column_ops -from data_manipulation.transformation.transform import apply_transformations from data_manipulation.utils import resolve_url from data_manipulation.validators import validate_schema_name, validate_table_name @@ -41,108 +29,36 @@ CHUNK_SIZE = int(os.getenv("DATAFEEDER_CHUNK_SIZE", 50000)) -def _get_table_row_count(table_name: str, engine: Engine, schema: str) -> int: - metadata = MetaData(schema=schema) - table = Table(table_name, metadata, autoload_with=engine) - count_query = select(func.count()).select_from(table) - - with engine.connect() as conn: - return conn.execute(count_query).scalar() or 0 - - -def _detect_file_encoding(file_path: str) -> str: - """Detect encoding for geospatial files. +def _build_pg_connection_string(engine: Engine) -> str: + """Build a GDAL ``PG:`` connection string from a SQLAlchemy engine. - Args: - file_path: Path to the file - - Returns: - Detected encoding string + WARNING: the returned string embeds the database password — never log it. """ - file_path_to_read = file_path - path = Path(file_path) - - # GeoJSON must be UTF-8 according to RFC 7946 - if path.suffix.lower() in (".geojson", ".json"): - return "utf-8" - - # Check for .cpg file (encoding file for shapefiles) - if path.suffix.lower() == ".shp": - cpg_file = path.with_suffix(".cpg") - if cpg_file.exists(): - file_path_to_read = str(cpg_file) - - try: - if path.suffix.lower() == ".zip" and zipfile.is_zipfile(file_path): - with zipfile.ZipFile(file_path) as zf: - names = zf.namelist() - if any(name.lower().endswith(".shp") for name in names): - member = next((n for n in names if n.lower().endswith(".cpg")), None) or next( - (n for n in names if n.lower().endswith(".dbf")), None - ) - if member is not None: - file_path_to_read = f"{file_path}!{member}" - with zf.open(member) as f: - sample = f.read(_ENCODING_DETECT_BYTES) - else: - sample = None - else: - sample = None - else: - with open(file_path_to_read, "rb") as f: - sample = f.read(_ENCODING_DETECT_BYTES) - encoding = chardet.detect(sample)["encoding"] if sample else None - except Exception as e: - logger.warning(f"Failed to detect encoding for {file_path_to_read}: {e}") - encoding = None - - return encoding or "utf-8" - - -def _read_file_encoded(file_path: str, i: int = 0) -> gpd.GeoDataFrame | pd.DataFrame: - """Read a chunk of a geospatial file, handling encoding detection. + url = engine.url + pg_conn_parts = [ + f"host={url.host}", + f"port={url.port or 5432}", + f"dbname={url.database}", + f"user={url.username}", + f"password={url.password}", + ] + return "PG:" + " ".join(part for part in pg_conn_parts if part.split("=", 1)[1]) - Reads ``CHUNK_SIZE`` rows starting at offset ``i * CHUNK_SIZE``. Returns an empty - frame once the offset is past the end of the file, which lets callers iterate until - the whole file has been ingested without ever loading it entirely in memory. - Args: - file_path: Path to the file - i: Zero-based chunk index +def _run_ogr2ogr(command: list[str], *, context: str) -> None: + """Run an ogr2ogr command, raising a clean error on failure. - Returns: - GeoDataFrame or DataFrame with the chunk data (empty when there is no more data) + WARNING: never log *command* itself — it may contain a ``PG:`` connection + string or ``GDAL_HTTP_USERPWD`` credentials. """ - logger.info("Use standard method") - if Path(file_path).suffix.lower() in (".parquet", ".geoparquet"): - ds = pq.ParquetDataset(file_path) - if i >= len(ds.fragments): - return gpd.GeoDataFrame() - try: - return gpd.read_parquet(ds.fragments[i].path) # type: ignore[arg-type] - except ValueError: - return pd.read_parquet(ds.fragments[i].path) - try: - # Try reading with UTF-8 first (common default) - result = gpd.read_file(file_path, rows=rows) # type: ignore[arg-type] - # pyogrio/pyarrow validate text lazily: a bad encoding doesn't raise here, it - # raises whenever the string columns are first materialized (e.g. much later - # in pandas.to_sql when writing to PostGIS). Force that materialization now - # so we can catch and handle it in this try block instead. - for column in result.columns: - if pd.api.types.is_string_dtype(result[column].dtype): - result[column].to_numpy() - return result - except (UnicodeDecodeError, ArrowException): - logger.warning( - "Failed to read file with UTF-8 encoding, attempting to detect encoding and read again." - ) - - # Detect encoding (mainly for shapefiles, others default to UTF-8) - encoding = _detect_file_encoding(file_path) - logger.warning("Detected encoding: %s", encoding) - return gpd.read_file(file_path, rows=rows, encoding=encoding) # type: ignore[arg-type] + subprocess.run(command, check=True, capture_output=True, text=True) + except FileNotFoundError as exc: + logger.error("ogr2ogr binary not found while %s", context) + raise Exception("ogr2ogr (GDAL) is not installed or not on PATH") from exc + except subprocess.CalledProcessError as exc: + logger.error("ogr2ogr failed while %s: %s", context, exc.stderr) + raise Exception(f"ogr2ogr failed: {exc.stderr}") def ingest_data_from_file_into_postgis( @@ -218,25 +134,7 @@ def ingest_data_from_ftp_into_postgis( # Download FTP file using urlretrieve urlretrieve(ftp_url_with_auth, temp_file_path) - i = 0 - while True: - data = _read_file_encoded(str(temp_file_path), i) - if data.empty: - break - write_data_to_postgis( - data, table_name, engine, schema, if_exists="replace" if i == 0 else "append" - ) - logger.debug( - "Ingested chunk %s (%s rows) from FTP %s into table %s", - i, - len(data), - url, - table_name, - ) - # A short read means the file is exhausted — avoid an extra empty read. - if len(data) < CHUNK_SIZE: - break - i += 1 + ingest_file_with_ogr2ogr(str(temp_file_path), table_name, engine, schema) # TODO: handle error for frontend except URLError as e: @@ -270,19 +168,6 @@ def ingest_data_from_ftp_into_postgis( raise -def _get_geo_column_from_table(table: Table) -> str | None: - """Return default geometry column or the name of the first geometry column found in a table.""" - if DEFAULT_GEOMETRY_COLUMN in table.c and isinstance( - table.c[DEFAULT_GEOMETRY_COLUMN], Geometry - ): - return DEFAULT_GEOMETRY_COLUMN - for column in table.columns: - if isinstance(column.type, Geometry): - logger.debug("Found geom column in source table: %s", column.name) - return column.name - return None - - def ingest_file_with_ogr2ogr( file_path: str, table_name: str, @@ -300,15 +185,7 @@ def ingest_file_with_ogr2ogr( validate_table_name(table_name, max_length=POSTGIS_TABLE_NAME_MAX_LENGTH) validate_schema_name(schema) - url = engine.url - pg_conn_parts = [ - f"host={url.host}", - f"port={url.port or 5432}", - f"dbname={url.database}", - f"user={url.username}", - f"password={url.password}", - ] - pg_connection = "PG:" + " ".join(part for part in pg_conn_parts if part.split("=", 1)[1]) + pg_connection = _build_pg_connection_string(engine) command = [ "ogr2ogr", @@ -327,14 +204,10 @@ def ingest_file_with_ogr2ogr( logger.info(f"Running ogr2ogr to ingest {file_path} into {schema}.{table_name}") - try: - # -------- - # WARNING: don't log the command as the PG connection string contains credentials - # -------- - subprocess.run(command, check=True, capture_output=True, text=True) - except subprocess.CalledProcessError as e: - logger.error(f"ogr2ogr failed ingesting {file_path}: {e.stderr}") - raise Exception(f"ogr2ogr failed: {e.stderr}") + # -------- + # WARNING: don't log the command as the PG connection string contains credentials + # -------- + _run_ogr2ogr(command, context=f"ingesting {file_path} into {schema}.{table_name}") def ingest_data_from_url_into_postgis( @@ -389,7 +262,6 @@ def ingest_data_from_url_into_postgis( with open(temp_file_path, "wb") as temp_file: temp_file.write(content) - start = time.time() ingest_file_with_ogr2ogr(str(temp_file_path), table_name, engine, schema) @@ -425,56 +297,39 @@ def ingest_data_from_database_into_postgis( """ validate_schema_name(source_schema) validate_table_name(source_table) + validate_schema_name(target_schema) + validate_table_name(target_table, max_length=POSTGIS_TABLE_NAME_MAX_LENGTH) logger.info( f"Ingesting data from {source_schema}.{source_table} into staging table {target_table}" ) - try: - metadata = MetaData(schema=source_schema) - table = Table(source_table, metadata, autoload_with=source_engine) - - geom = _get_geo_column_from_table(table) - - # A stable ORDER BY is required so that LIMIT/OFFSET pagination returns each row - # exactly once. Prefer the primary key; fall back to all columns when absent. - order_columns = list(table.primary_key.columns) or list(table.columns) - base_query = select(table).order_by(*order_columns) - - # Read and write one chunk at a time to keep the memory footprint low for large tables. - i = 0 - while True: - query = base_query.limit(CHUNK_SIZE).offset(i * CHUNK_SIZE) - if geom is not None: - data = gpd.read_postgis(query, con=source_engine, geom_col=geom) # type: ignore[call-overload] - else: - data = pd.read_sql(query, source_engine) - if data.empty: - break - - write_data_to_postgis( - data, - target_table, - target_engine, - target_schema, - if_exists="replace" if i == 0 else "append", - ) - logger.debug( - "Ingested chunk %s (%s rows) from table %s into table %s", - i, - len(data), - source_table, - target_table, - ) - - # A short read means we have reached the end of the table — avoid an extra empty query. - if len(data) < CHUNK_SIZE: - break - i += 1 + source_connection = _build_pg_connection_string(source_engine) + target_connection = _build_pg_connection_string(target_engine) - except Exception as e: - logger.error(f"Error ingesting data from {source_schema}.{source_table}: {e}") - raise + command = [ + "ogr2ogr", + "-f", + "PostgreSQL", + target_connection, + source_connection, + f"{source_schema}.{source_table}", + "-nln", + f"{target_schema}.{target_table}", + "-overwrite", + "-lco", + f"GEOMETRY_NAME={DEFAULT_GEOMETRY_COLUMN}", + "-lco", + f"SCHEMA={target_schema}", + ] + + # -------- + # WARNING: don't log the command — both PG connection strings contain credentials + # -------- + _run_ogr2ogr( + command, + context=f"ingesting {source_schema}.{source_table} into {target_schema}.{target_table}", + ) _GDAL_PROTOCOL_PREFIX = {"wfs": "WFS", "ogcFeatures": "OAPIF"} @@ -542,8 +397,9 @@ def ingest_data_from_ogc_service_into_postgis( table_name: str, engine: Engine, schema: str = DEFAULT_SCHEMA, + auth: tuple[str, str] | None = None, ) -> None: - """Ingest a WFS or OGC API Features layer into PostGIS using GeoPandas/GDAL. + """Ingest a WFS or OGC API Features layer into PostGIS using ogr2ogr/GDAL. `protocol` is the service protocol as stored: 'wfs' or 'ogcFeatures'. The GDAL driver prefix (WFS: / OAPIF:) is built internally. @@ -551,293 +407,41 @@ def ingest_data_from_ogc_service_into_postgis( `layer_name` maps directly to the GDAL layer name in both cases: - WFS: the WFS typename (e.g. "ns:buildings"), set as identifierInService by geonetwork-ui - OAPIF: the collection ID (e.g. "buildings"), the `name` from OgcApiEndpoint.allCollections - No additional parameters are needed beyond layer=layer_name for basic ingestion. + + `auth`, when provided, is an (username, password) tuple passed to GDAL as + HTTP Basic credentials via the GDAL_HTTP_USERPWD config option. """ gdal_prefix = _GDAL_PROTOCOL_PREFIX.get(protocol, "WFS") normalized_url = _normalize_oapif_url(service_url) if protocol == "ogcFeatures" else service_url gdal_source = f"{gdal_prefix}:{normalized_url}" logger.info(f"Ingesting OGC layer '{layer_name}' from {gdal_source} into {table_name}") - wfs_json_fmt = _wfs_json_output_format(service_url) if protocol == "wfs" else None - use_wfs_fallback = protocol == "wfs" and wfs_json_fmt is None - if use_wfs_fallback: - logger.warning( - "WFS at %s does not advertise a JSON output format; falling back to default WFS output format", - service_url, - ) - else: - logger.info( - "WFS at %s advertises JSON output format '%s'; using it for chunked ingestion", - service_url, - wfs_json_fmt, - ) - - try: - i = 0 - while True: - if wfs_json_fmt: - url = _wfs_geojson_chunk_url( - service_url, layer_name, i * CHUNK_SIZE, CHUNK_SIZE, wfs_json_fmt - ) - gdf = gpd.read_file(url) - else: - rows = slice(i * CHUNK_SIZE, i * CHUNK_SIZE + CHUNK_SIZE, None) - gdf = gpd.read_file(gdal_source, layer=layer_name, rows=rows) - if gdf.empty: - break - chunk_len = len(gdf) - # OGC API Features collections may have no geometry — treat as tabular data in that case - data: gpd.GeoDataFrame | pd.DataFrame = gdf - if gdf.geometry.isna().all(): - logger.info( - f"Layer '{layer_name}' has no valid geometries; ingesting as tabular data." - ) - data = pd.DataFrame(gdf.drop(columns=str(gdf.geometry.name))) - write_data_to_postgis( - data, table_name, engine, schema, if_exists="replace" if i == 0 else "append" - ) - logger.debug( - "Ingested chunk %s (%s rows) from OGC service %s into table %s", - i, - chunk_len, - gdal_source, - table_name, - ) - # A short read means the layer is exhausted — avoid an extra empty request. - if chunk_len < CHUNK_SIZE: - break - i += 1 - except Exception as e: - logger.error(f"Error ingesting OGC layer '{layer_name}' from {gdal_source}: {e}") - raise - - -def read_data_from_postgis( - table_name: str, - engine: Engine, - schema: str | None = None, - limit: int | None = None, - columns: list[ColumnConfig] | None = None, - offset: int | None = None, -) -> pd.DataFrame: - """Read data from a PostGIS table. - - When *columns* is provided, exclusion and filtering are applied at the SQL - level so that WHERE clauses execute *before* any LIMIT. The resulting - SQLAlchemy ``Select`` object is passed directly to ``gpd.read_postgis`` / - ``pd.read_sql`` — never compiled to a string — so all filter values remain - bound parameters (no SQL injection risk). - - Args: - table_name: Name of the table to read. - engine: SQLAlchemy engine. - schema: PostgreSQL schema name (optional). - limit: Maximum number of rows to return (applied after filters). - columns: Optional list of column configurations. When provided, - excluded columns are omitted from the SELECT and active filters are - applied as WHERE clauses. When ``None``, all columns are returned - without filtering. - offset: Number of rows to skip (applied after filters). When provided, - a stable ``ORDER BY`` is added so that ``LIMIT``/``OFFSET`` pagination - returns each row exactly once across successive calls. - - Returns: - GeoDataFrame or DataFrame containing the (filtered) table data. - """ - # Validate identifiers to prevent SQL injection - validate_table_name(table_name) - if schema: - validate_schema_name(schema) - - try: - # Use SQLAlchemy Core to safely construct the query - metadata = MetaData(schema=schema) - table = Table(table_name, metadata, autoload_with=engine) - - if columns is not None: - select_cols, where_clauses = build_sql_column_ops(columns, table) - - if not select_cols: - # All columns excluded — return an empty DataFrame immediately - logger.warning( - f"All columns excluded for table {schema}.{table_name}, returning empty DataFrame" - ) - return pd.DataFrame() - - query = select(*select_cols) - if where_clauses: - query = query.where(*where_clauses) - - has_geom = any(col.key == DEFAULT_GEOMETRY_COLUMN for col in select_cols) - order_columns = list(select_cols) - else: - query = select(table) - has_geom = DEFAULT_GEOMETRY_COLUMN in table.c - order_columns = list(table.primary_key.columns) or list(table.columns) - - # A stable ORDER BY is required for deterministic LIMIT/OFFSET pagination. - if offset is not None: - query = query.order_by(*order_columns) - - if limit is not None and limit > 0: - query = query.limit(limit) - - if offset is not None and offset > 0: - query = query.offset(offset) - - # Pass the Select object directly — both pd.read_sql and gpd.read_postgis - # accept a SQLAlchemy Selectable natively in SQLAlchemy 2.x. - # This guarantees that all filter values remain bound parameters. - if has_geom: - return gpd.read_postgis(query, con=engine, geom_col=DEFAULT_GEOMETRY_COLUMN) # type: ignore[call-overload] - else: - return pd.read_sql(query, engine) - except Exception as e: - logger.error(f"Error reading data from PostGIS table {schema}.{table_name}: {e}") - raise - - -def read_and_transform_data( - table_name: str, - engine: Engine, - schema: str | None = None, - config: IntegrityTransformation | None = None, - limit: int | None = None, - offset: int | None = None, -) -> pd.DataFrame: - """Single pipeline entry point: read data and apply all transformations. - - Combines ``read_data_from_postgis`` (SQL-level exclusion + filtering) with - ``apply_transformations`` (in-memory rename, cast, projection) in one call. - - Both the backend GET preview (``limit=10``, config from DB) and the Airflow - process DAG (``limit=None``, config from DAG params) call this function - identically, which is the architectural guarantee for FR-021 consistency. - - Args: - table_name: Name of the staging table. - engine: SQLAlchemy engine. - schema: PostgreSQL schema name (optional). - config: Transformation configuration. ``None`` = return raw data - unchanged (no column filtering, no transformations). - limit: Row limit (``None`` = all rows). - offset: Number of rows to skip for chunked reads (``None`` = from the - start). Enables deterministic ``LIMIT``/``OFFSET`` pagination. - - Returns: - Transformed GeoDataFrame or DataFrame. - """ - columns = config.columns if config is not None else None - data = read_data_from_postgis( - table_name, engine, schema=schema, limit=limit, columns=columns, offset=offset - ) - - if config is None: - return data - - return apply_transformations(data, config) - - -def write_data_to_postgis( - data: gpd.GeoDataFrame | pd.DataFrame, - table_name: str, - engine: Engine, - schema: str = DEFAULT_SCHEMA, - create_id: bool = False, - if_exists: Literal["fail", "replace", "append"] = "replace", -) -> None: - """Write a GeoDataFrame or DataFrame to a PostGIS table. - - Args: - data: GeoDataFrame or DataFrame to write - table_name: Name of the target table - engine: SQLAlchemy engine - schema: PostgreSQL schema name (optional) - create_id: If True, add an 'id_datafeeder' UUID column as primary key - """ - # Validate identifiers to prevent SQL injection validate_table_name(table_name, max_length=POSTGIS_TABLE_NAME_MAX_LENGTH) validate_schema_name(schema) - try: - if not isinstance(data, gpd.GeoDataFrame): # DataFrame - # Ensure there is no geom column - if DEFAULT_GEOMETRY_COLUMN in data.columns: - logger.warning( - f"DataFrame already has a '{DEFAULT_GEOMETRY_COLUMN}' column. Dropping it before writing to PostGIS." - ) - data.drop(columns=[DEFAULT_GEOMETRY_COLUMN], inplace=True) - - # Write data to PostGIS as a regular table - data.to_sql(table_name, engine, if_exists=if_exists, schema=schema, index=False) - else: # GeoDataFrame - # Ensure the geometry column is named 'geom' for PostGIS convention - if data.active_geometry_name is None: - logger.info("GeoDataFrame has no active geometry column set.") - - # Ensure there is no geom column - if DEFAULT_GEOMETRY_COLUMN in data.columns: - logger.warning( - f"GeoDataFrame already has a '{DEFAULT_GEOMETRY_COLUMN}' column." - " Dropping it before writing to PostGIS." - ) - data.drop(columns=[DEFAULT_GEOMETRY_COLUMN], inplace=True) - - elif data.active_geometry_name == DEFAULT_GEOMETRY_COLUMN: - logger.info( - f"GeoDataFrame has '{DEFAULT_GEOMETRY_COLUMN}' as active geometry column." - ) - else: - logger.info( - f"GeoDataFrame has '{data.active_geometry_name}' as active geometry column." - ) + pg_connection = _build_pg_connection_string(engine) - if DEFAULT_GEOMETRY_COLUMN in data.columns: - logger.warning( - f"GeoDataFrame already has a '{DEFAULT_GEOMETRY_COLUMN}' column." - " Overwriting it with the active geometry column." - ) - else: - logger.info(f"Renaming active geometry column to '{DEFAULT_GEOMETRY_COLUMN}'") - data.rename_geometry(DEFAULT_GEOMETRY_COLUMN, inplace=True) - - # Write data to PostGIS. Force a generic GEOMETRY column type (instead of letting - # GeoPandas infer Point/LineString/... from the current frame) so that chunked - # appends with heterogeneous geometry types — or a first chunk that happens to be - # homogeneous — don't clash with later chunks. The SRID is pinned to the data CRS - # so PostGIS still rejects mismatched projections. - geom_dtype: dict[str, Geometry] | None = None - if data.active_geometry_name is not None: - srid = data.crs.to_epsg() if data.crs is not None else None - geom_dtype = { - data.active_geometry_name: Geometry(geometry_type="GEOMETRY", srid=srid or 0) - } - data.to_postgis( - table_name, - engine, - if_exists=if_exists, - schema=schema, - index=False, - dtype=geom_dtype, - ) - - if create_id: - with engine.connect() as conn: - conn.execute( - text( - f'ALTER TABLE "{schema}"."{table_name}" ' - f"ADD COLUMN id_datafeeder UUID DEFAULT gen_random_uuid() NOT NULL" - ) - ) - conn.execute( - text(f'ALTER TABLE "{schema}"."{table_name}" ADD PRIMARY KEY (id_datafeeder)') - ) - conn.commit() - logger.info(f"Added 'id_datafeeder' UUID primary key column to {schema}.{table_name}") + command = [ + "ogr2ogr", + "-f", + "PostgreSQL", + pg_connection, + gdal_source, + layer_name, + "-nln", + f"{schema}.{table_name}", + "-overwrite", + "-lco", + f"GEOMETRY_NAME={DEFAULT_GEOMETRY_COLUMN}", + "-lco", + f"SCHEMA={schema}", + ] - # Log the number of inserted rows - row_count = _get_table_row_count(table_name, engine, schema) - logger.info(f"Successfully inserted {row_count} rows into {schema}.{table_name}") - except Exception as e: - logger.error(f"Error writing data to PostGIS table {schema}.{table_name}: {e}") - raise + if auth is not None: + username, password = auth + # -------- + # WARNING: don't log the command — GDAL_HTTP_USERPWD contains credentials + # -------- + command += ["--config", "GDAL_HTTP_USERPWD", f"{username}:{password}"] + + _run_ogr2ogr(command, context=f"ingesting OGC layer '{layer_name}' into {schema}.{table_name}") diff --git a/libs/data_manipulation/src/data_manipulation/transformation/filter_sql.py b/libs/data_manipulation/src/data_manipulation/transformation/filter_sql.py index 7a73e7da..76c69322 100644 --- a/libs/data_manipulation/src/data_manipulation/transformation/filter_sql.py +++ b/libs/data_manipulation/src/data_manipulation/transformation/filter_sql.py @@ -11,7 +11,7 @@ from sqlalchemy import Column, ColumnElement, Table, Text, cast -from data_manipulation.models import ColumnConfig, FilterOperator +from data_manipulation.models import ColumnConfig, ColumnFilter, FilterOperator logger = logging.getLogger(__name__) @@ -31,6 +31,31 @@ def _escape_like(value: str) -> str: return value +def build_filter_clause(col: ColumnElement[Any], column_filter: ColumnFilter) -> ColumnElement[Any]: + """Build a single case-insensitive ILIKE WHERE clause for a column. + + The column is cast to TEXT so all types (int, date, etc.) are compared + uniformly. The filter value is escaped so '%', '_', and '\\' are treated as + literal characters, and is always passed as a bound parameter (no SQL + injection risk). + + Pattern shapes: + EXACTLY -> "value" — full string must match + CONTAINS -> "%value%" — value can appear anywhere + STARTS_WITH -> "value%" — string must begin with value + """ + col_as_text = cast(col, Text) + escaped = _escape_like(column_filter.value) + operator = column_filter.operator + + if operator == FilterOperator.EXACTLY: + return col_as_text.ilike(escaped, escape=_LIKE_ESCAPE_CHAR) + if operator == FilterOperator.CONTAINS: + return col_as_text.ilike(f"%{escaped}%", escape=_LIKE_ESCAPE_CHAR) + # STARTS_WITH + return col_as_text.ilike(f"{escaped}%", escape=_LIKE_ESCAPE_CHAR) + + def build_sql_column_ops( columns: list[ColumnConfig], table: Table, @@ -71,27 +96,6 @@ def build_sql_column_ops( select_cols.append(col) if col_config.filter is not None: - # Cast the column to TEXT so all types (int, date, etc.) are compared uniformly. - # All three operators use ILIKE for case-insensitive matching. - # - # The filter value is always escaped before being embedded in a pattern: - # '%', '_', and '\' are special in ILIKE and must be escaped so they are - # treated as literal characters rather than wildcards. - # - # Pattern shapes: - # EXACTLY → "value" — full string must match - # CONTAINS → "%value%" — value can appear anywhere - # STARTS_WITH → "value%" — string must begin with value - col_as_text = cast(col, Text) - filter_value = col_config.filter.value - operator = col_config.filter.operator - escaped = _escape_like(filter_value) - - if operator == FilterOperator.EXACTLY: - where_clauses.append(col_as_text.ilike(escaped, escape=_LIKE_ESCAPE_CHAR)) - elif operator == FilterOperator.CONTAINS: - where_clauses.append(col_as_text.ilike(f"%{escaped}%", escape=_LIKE_ESCAPE_CHAR)) - elif operator == FilterOperator.STARTS_WITH: - where_clauses.append(col_as_text.ilike(f"{escaped}%", escape=_LIKE_ESCAPE_CHAR)) + where_clauses.append(build_filter_clause(col, col_config.filter)) return select_cols, where_clauses diff --git a/libs/data_manipulation/src/data_manipulation/transformation/sql_transform.py b/libs/data_manipulation/src/data_manipulation/transformation/sql_transform.py new file mode 100644 index 00000000..4794f7b4 --- /dev/null +++ b/libs/data_manipulation/src/data_manipulation/transformation/sql_transform.py @@ -0,0 +1,502 @@ +"""SQL-native transformation pipeline. + +This module replaces the former in-memory (geopandas/pandas) transformation +layer. Every transformation — column selection/exclusion, rename, type cast, +filtering, projection and geometry construction — is expressed as +*parameterized SQL* (SQLAlchemy Core) and executed **inside PostGIS**. Data +never leaves the database except for the small bounded preview. + +A single :func:`build_transformation_select` produces the canonical +transformation ``SELECT`` used by **both**: + +* :func:`transform_staging_to_final` — ``CREATE TABLE AS `` + ``LIMIT`` with in-database + geometry serialization (the backend preview path). + +Building the query once is the architectural guarantee that preview and process +apply identical transformations (FR-021). + +Projection semantics mirror the previous geopandas behaviour exactly: + +* ``force_projection.type`` **relabels** the SRID without reprojecting + coordinates (geopandas ``set_crs``) → ``ST_SetSRID(geom, srid)``. +* Map preview reprojects to EPSG:4326 for display (geopandas ``to_crs``) → + ``ST_AsGeoJSON(ST_Transform(geom, 4326))``. +""" + +import datetime +import json +import logging +import re +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from sqlalchemy import ( + Column, + ColumnElement, + MetaData, + Table, + Text, + cast, + func, + literal_column, + select, + text, +) +from sqlalchemy.engine import Connection, Engine +from sqlalchemy.sql import Select + +from data_manipulation.constants import DEFAULT_GEOMETRY_COLUMN, POSTGIS_TABLE_NAME_MAX_LENGTH +from data_manipulation.logging import configure_logging +from data_manipulation.models import CastType, IntegrityTransformation +from data_manipulation.transformation.filter_sql import build_filter_clause +from data_manipulation.validators import validate_schema_name, validate_table_name + +logger = logging.getLogger(__name__) +configure_logging(logger) + +DEFAULT_SRID = 4326 +DEFAULT_CRS = f"EPSG:{DEFAULT_SRID}" + +# Boolean string tokens, matched case-insensitively (mirror of the former +# pandas-based parser so text-encoded booleans cast identically). +_BOOL_TRUE = ("true", "1", "yes", "on", "t", "y") +_BOOL_FALSE = ("false", "0", "no", "off", "f", "n") + +_EPSG_RE = re.compile(r"(?:EPSG:)?(\d{4,6})$", re.IGNORECASE) + + +def _parse_srid(projection_type: str | None) -> int | None: + """Parse an ``EPSG:NNNN`` (or bare ``NNNN``) string into an int SRID. + + Returns ``None`` when *projection_type* is empty or not parseable, meaning + "no explicit projection requested". + """ + if not projection_type: + return None + match = _EPSG_RE.match(projection_type.strip()) + if not match: + logger.warning("Could not parse SRID from projection '%s'", projection_type) + return None + return int(match.group(1)) + + +@dataclass +class TransformationQuery: + """Result of :func:`build_transformation_select`. + + Attributes: + select: The canonical transformation ``SELECT`` against the staging table. + geom_column: Name of the geometry output column (always + :data:`DEFAULT_GEOMETRY_COLUMN`) when the result is geographic, else + ``None``. + property_columns: Ordered names of the non-geometry output columns. + """ + + select: Select[Any] + geom_column: str | None + property_columns: list[str] = field(default_factory=list) + + +# --------------------------------------------------------------------------- # +# Safe-cast helper functions (installed idempotently in the database) +# --------------------------------------------------------------------------- # + +_bool_true_sql = ", ".join(f"'{v}'" for v in _BOOL_TRUE) +_bool_false_sql = ", ".join(f"'{v}'" for v in _BOOL_FALSE) + +# Each function coerces invalid input to NULL, matching the previous +# pandas ``errors="coerce"`` behaviour. Marked IMMUTABLE since the mapping is +# deterministic. +_CAST_HELPER_DDL = f""" +CREATE OR REPLACE FUNCTION public.datafeeder_to_numeric(v text) +RETURNS double precision AS $$ +BEGIN + RETURN v::double precision; +EXCEPTION WHEN others THEN + RETURN NULL; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE OR REPLACE FUNCTION public.datafeeder_to_date(v text) +RETURNS timestamp without time zone AS $$ +BEGIN + RETURN v::timestamp without time zone; +EXCEPTION WHEN others THEN + RETURN NULL; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE OR REPLACE FUNCTION public.datafeeder_to_bool(v text) +RETURNS boolean AS $$ +DECLARE + s text := lower(trim(v)); +BEGIN + IF s IN ({_bool_true_sql}) THEN + RETURN true; + ELSIF s IN ({_bool_false_sql}) THEN + RETURN false; + ELSE + RETURN NULL; + END IF; +END; +$$ LANGUAGE plpgsql IMMUTABLE; +""" + + +def ensure_cast_helpers(conn: Connection) -> None: + """Create the idempotent safe-cast SQL helper functions if missing.""" + conn.execute(text(_CAST_HELPER_DDL)) + + +def _cast_expr(col: ColumnElement[Any], cast_type: CastType) -> ColumnElement[Any]: + """Return a SQL expression casting *col* to *cast_type*, coercing on error.""" + col_text = cast(col, Text) + if cast_type == CastType.BOOLEAN: + return func.public.datafeeder_to_bool(col_text) + if cast_type == CastType.NUMERIC: + return func.public.datafeeder_to_numeric(col_text) + if cast_type == CastType.DATE: + return func.public.datafeeder_to_date(col_text) + if cast_type == CastType.TEXT: + return col_text + return col + + +def _geom_ref() -> ColumnElement[Any]: + """Reference the staging geometry column as a plain (untyped) column. + + Using :func:`literal_column` instead of the reflected GeoAlchemy2 column + avoids the automatic ``ST_AsEWKB(...)`` read-wrapping, so the geometry stays + a native ``geometry`` value usable by ``CREATE TABLE AS`` and ``ST_*``. + """ + return literal_column(f'"{DEFAULT_GEOMETRY_COLUMN}"') + + +def build_transformation_select( + table: Table, config: IntegrityTransformation | None +) -> TransformationQuery: + """Build the canonical transformation ``SELECT`` for a staging table. + + Applies, entirely in SQL: + + * column selection + exclusion (excluded columns are never selected), + * rename (``new_name``) via column labels, + * type cast (boolean/numeric/text/date) with invalid values coerced to NULL, + * per-column ILIKE filters as bound-parameter ``WHERE`` clauses, + * geometry handling: + - X/Y columns → ``ST_SetSRID(ST_MakePoint(x, y), srid)``, + - existing geom + forced projection → ``ST_SetSRID(geom, srid)``, + - existing geom, no forced projection → passthrough. + + Args: + table: Reflected SQLAlchemy ``Table`` for the staging table. + config: Transformation configuration. ``None`` selects all columns + unchanged (passthrough), preserving any geometry column. + + Returns: + A :class:`TransformationQuery`. + """ + columns = config.columns if config is not None else None + force = config.force_projection if config is not None else None + + geom_srid = _parse_srid(force.type) if force else None + x_col = force.x_column if force and force.x_column else None + y_col = force.y_column if force and force.y_column else None + build_point = bool(x_col and y_col) + + select_exprs: list[ColumnElement[Any]] = [] + where_clauses: list[ColumnElement[Any]] = [] + property_columns: list[str] = [] + geom_out: str | None = None + + def _emit_existing_geom() -> None: + nonlocal geom_out + geom = _geom_ref() + expr = func.ST_SetSRID(geom, geom_srid) if geom_srid is not None else geom + select_exprs.append(expr.label(DEFAULT_GEOMETRY_COLUMN)) + geom_out = DEFAULT_GEOMETRY_COLUMN + + if columns: + for col_config in columns: + if col_config.excluded: + continue + name = col_config.original_name + if name not in table.c: + logger.warning("Column '%s' not found in table '%s', skipping", name, table.name) + continue + + if name == DEFAULT_GEOMETRY_COLUMN: + # Geometry is emitted separately; skip here unless we keep it as-is. + if not build_point: + _emit_existing_geom() + continue + + col: Column[Any] = table.c[name] + expr = _cast_expr(col, col_config.cast_type) if col_config.cast_type else col + effective = col_config.new_name or col_config.original_name + select_exprs.append(expr.label(effective)) + property_columns.append(effective) + + if col_config.filter is not None: + where_clauses.append(build_filter_clause(col, col_config.filter)) + else: + for col in table.c: + if col.name == DEFAULT_GEOMETRY_COLUMN: + if not build_point: + _emit_existing_geom() + continue + select_exprs.append(col) + property_columns.append(col.name) + + if build_point and x_col is not None and y_col is not None: + srid = geom_srid if geom_srid is not None else DEFAULT_SRID + x_expr = func.public.datafeeder_to_numeric(cast(table.c[x_col], Text)) + y_expr = func.public.datafeeder_to_numeric(cast(table.c[y_col], Text)) + point = func.ST_SetSRID(func.ST_MakePoint(x_expr, y_expr), srid) + select_exprs.append(point.label(DEFAULT_GEOMETRY_COLUMN)) + geom_out = DEFAULT_GEOMETRY_COLUMN + + stmt = select(*select_exprs).select_from(table) + if where_clauses: + stmt = stmt.where(*where_clauses) + + return TransformationQuery(select=stmt, geom_column=geom_out, property_columns=property_columns) + + +# --------------------------------------------------------------------------- # +# Process path: staging -> final (CREATE TABLE AS) +# --------------------------------------------------------------------------- # + + +def transform_staging_to_final( + staging_table: str, + final_table: str, + engine: Engine, + config: IntegrityTransformation | None = None, + staging_schema: str = "staging", + final_schema: str = "data", + create_id: bool = True, +) -> int: + """Transform a staging table into a final table entirely in the database. + + Runs ``CREATE TABLE . AS `` + and optionally adds an ``id_datafeeder`` UUID primary key. No data is loaded + into Python memory. + + Args: + staging_table: Source staging table name. + final_table: Target final table name. + engine: SQLAlchemy engine for the (single) PostGIS database. + config: Transformation configuration (``None`` = passthrough copy). + staging_schema: Schema of the staging table. + final_schema: Schema of the final table. + create_id: When True, add an ``id_datafeeder`` UUID primary key. + + Returns: + Number of rows written to the final table. + """ + validate_schema_name(staging_schema) + validate_schema_name(final_schema) + validate_table_name(staging_table) + validate_table_name(final_table, max_length=POSTGIS_TABLE_NAME_MAX_LENGTH) + + metadata = MetaData(schema=staging_schema) + table = Table(staging_table, metadata, autoload_with=engine) + + tq = build_transformation_select(table, config) + compiled = tq.select.compile(dialect=engine.dialect) + ctas = f'CREATE TABLE "{final_schema}"."{final_table}" AS {compiled.string}' + + with engine.connect() as conn: + ensure_cast_helpers(conn) + # Replace semantics: CREATE TABLE AS requires the target not to exist. + conn.execute(text(f'DROP TABLE IF EXISTS "{final_schema}"."{final_table}"')) + # exec_driver_sql passes the DBAPI-paramstyle SQL + params straight + # through, keeping every filter value a bound parameter. + conn.exec_driver_sql(ctas, compiled.params) + + if create_id: + conn.execute( + text( + f'ALTER TABLE "{final_schema}"."{final_table}" ' + f"ADD COLUMN id_datafeeder UUID DEFAULT gen_random_uuid() NOT NULL" + ) + ) + conn.execute( + text( + f'ALTER TABLE "{final_schema}"."{final_table}" ADD PRIMARY KEY (id_datafeeder)' + ) + ) + + row_count = ( + conn.execute(text(f'SELECT count(*) FROM "{final_schema}"."{final_table}"')).scalar() + or 0 + ) + conn.commit() + + logger.info( + "Transformed %s.%s -> %s.%s (%d rows)", + staging_schema, + staging_table, + final_schema, + final_table, + row_count, + ) + return int(row_count) + + +# --------------------------------------------------------------------------- # +# Preview path: staging -> bounded rows + GeoJSON +# --------------------------------------------------------------------------- # + + +@dataclass +class PreviewResult: + """Bounded, JSON-serializable preview of a transformed staging table. + + Attributes: + rows: Tabular rows (geometry rendered as WKT under ``geom``). + geojson: A GeoJSON ``FeatureCollection`` (EPSG:4326) or ``None``. + is_geographic: Whether the transformed result has a geometry column. + """ + + rows: list[dict[str, Any]] + geojson: dict[str, Any] | None + is_geographic: bool + + +def _json_safe(value: Any) -> Any: + """Convert DB-native scalar types to JSON-serializable equivalents.""" + if value is None: + return None + if isinstance(value, Decimal): + return float(value) + if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + return value.isoformat() + if isinstance(value, (bytes, bytearray, memoryview)): + return bytes(value).hex() + return value + + +def read_transformed_preview( + staging_table: str, + engine: Engine, + config: IntegrityTransformation | None = None, + schema: str = "staging", + limit: int | None = 10, +) -> PreviewResult: + """Read a bounded, transformed preview of a staging table. + + Builds the same transformation ``SELECT`` as the process path, applies a + ``LIMIT`` and serializes geometry **in the database**: WKT for the tabular + rows (``geom``) and GeoJSON reprojected to EPSG:4326 for map display. No + geopandas/pandas involved. + + Args: + staging_table: Staging table name. + engine: SQLAlchemy engine. + config: Transformation configuration (``None`` = passthrough). + schema: Staging schema. + limit: Maximum number of rows (``None`` = no limit). + + Returns: + A :class:`PreviewResult`. + """ + validate_table_name(staging_table) + validate_schema_name(schema) + + metadata = MetaData(schema=schema) + table = Table(staging_table, metadata, autoload_with=engine) + + tq = build_transformation_select(table, config) + core = tq.select.subquery() + + geojson_label = "__geojson__" + select_cols: list[ColumnElement[Any]] = [] + geom_column = tq.geom_column + has_geom = geom_column is not None + + for col in core.c: + if col.name == geom_column: + select_cols.append(func.ST_AsText(col).label(DEFAULT_GEOMETRY_COLUMN)) + else: + select_cols.append(col) + if geom_column is not None: + geom_col = core.c[geom_column] + select_cols.append( + func.ST_AsGeoJSON(func.ST_Transform(geom_col, DEFAULT_SRID)).label(geojson_label) + ) + + stmt = select(*select_cols) + if limit is not None and limit > 0: + stmt = stmt.limit(limit) + + rows: list[dict[str, Any]] = [] + features: list[dict[str, Any]] = [] + + with engine.connect() as conn: + ensure_cast_helpers(conn) + result = conn.execute(stmt) + for mapping in result.mappings(): + record = dict(mapping) + geometry_geojson = record.pop(geojson_label, None) if has_geom else None + + row = {key: _json_safe(val) for key, val in record.items()} + rows.append(row) + + if has_geom and geometry_geojson: + properties = { + key: val for key, val in row.items() if key != DEFAULT_GEOMETRY_COLUMN + } + features.append( + { + "type": "Feature", + "geometry": json.loads(geometry_geojson), + "properties": properties, + } + ) + + geojson = {"type": "FeatureCollection", "features": features} if has_geom and features else None + return PreviewResult(rows=rows, geojson=geojson, is_geographic=has_geom) + + +# --------------------------------------------------------------------------- # +# CRS detection +# --------------------------------------------------------------------------- # + + +def detect_table_srid(staging_table: str, engine: Engine, schema: str | None = None) -> str | None: + """Return the CRS (``EPSG:NNNN``) of a staging table's geometry, if any. + + Reads ``ST_SRID`` from the first geometry row. Returns ``None`` when the + table has no geometry column or no detectable SRID. + """ + validate_table_name(staging_table) + if schema: + validate_schema_name(schema) + + try: + metadata = MetaData(schema=schema) + table = Table(staging_table, metadata, autoload_with=engine) + except Exception as exc: # pragma: no cover - defensive + logger.warning("Could not reflect table for SRID detection: %s", exc) + return None + + if DEFAULT_GEOMETRY_COLUMN not in table.c: + return None + + try: + with engine.connect() as conn: + srid = conn.execute( + select(func.ST_SRID(_geom_ref())).select_from(table).limit(1) + ).scalar() + except Exception as exc: + logger.warning("Could not detect original projection: %s", exc) + return None + + if srid: + return f"EPSG:{srid}" + return None diff --git a/libs/data_manipulation/src/data_manipulation/transformation/transform.py b/libs/data_manipulation/src/data_manipulation/transformation/transform.py deleted file mode 100644 index e52abc2c..00000000 --- a/libs/data_manipulation/src/data_manipulation/transformation/transform.py +++ /dev/null @@ -1,178 +0,0 @@ -import logging - -import geopandas as gpd -import pandas as pd -from shapely import wkb, wkt - -from data_manipulation.constants import DEFAULT_GEOMETRY_COLUMN -from data_manipulation.models import IntegrityTransformation -from data_manipulation.transformation.transform_columns import cast_column_types, rename_columns -from data_manipulation.transformation.transform_geom_point import create_geometries_from_columns -from data_manipulation.transformation.transform_projection import apply_projection - -logger = logging.getLogger(__name__) - -DEFAULT_CRS = "EPSG:4326" - - -def _parse_geometry(geom_value: str): - """Parse geometry from either WKT or WKB hexadecimal format. - - Args: - geom_value: Geometry string (WKT or WKB hex) - - Returns: - Shapely geometry object - """ - if not geom_value or pd.isna(geom_value): - return None - - # Check if it's WKB hex (starts with hex digits) - if all(c in "0123456789ABCDEFabcdef" for c in geom_value): - try: - return wkb.loads(geom_value, hex=True) - except Exception: - pass - - # Try WKT format - try: - return wkt.loads(geom_value) - except Exception as e: - logger.warning(f"Failed to parse geometry: {e}") - return None - - -def _convert_geom_column_to_geodataframe(df: pd.DataFrame, projection: str) -> gpd.GeoDataFrame: - """Convert DataFrame with 'geom' column to GeoDataFrame. - - Args: - df: DataFrame with 'geom' column containing WKT or WKB geometries - projection: CRS to apply to the GeoDataFrame - - Returns: - GeoDataFrame with geometry column set - """ - logger.info("Converting 'geom' column to geometry") - try: - # Parse geometries from 'geom' column (supports both WKT and WKB) - geometries = df[DEFAULT_GEOMETRY_COLUMN].apply(_parse_geometry) - - # Create GeoDataFrame with geometry column - gdf = gpd.GeoDataFrame(df, geometry=geometries, crs=projection) # type: ignore[no-any-return] - - return gdf - except Exception as e: - logger.error(f"Failed to convert 'geom' column to geometry: {e}") - raise ValueError("i18nerror.transformation.geom_column_conversion_failed") - - -def _apply_projection_transformation( - df: pd.DataFrame, - projection: str, - x_column: str | None = None, - y_column: str | None = None, -) -> pd.DataFrame: - """Apply projection/CRS transformation to geometries. - - Args: - df: Input GeoDataFrame or DataFrame - projection: Optional Target CRS/projection (e.g., 'EPSG:4326') - x_column: Optional X/longitude column name for creating geometries - y_column: Optional Y/latitude column name for creating geometries - - Returns: - Transformed GeoDataFrame with projection applied - """ - if y_column and x_column: - # Create geometries from coordinate columns if specified - # also apply projection - logger.info( - f"Creating geometries from columns {x_column}/{y_column} with projection {projection}" - ) - try: - df = create_geometries_from_columns(df, projection, x_column, y_column) - except Exception: - raise ValueError("i18nerror.transformation.geometry_creation_failed") - - elif y_column is None and x_column is not None or y_column is not None and x_column is None: - raise ValueError("i18nerror.transformation.columns_both_required") - - elif isinstance(df, gpd.GeoDataFrame): - # Apply projection to GeoDataFrame - logger.info(f"Applying projection {projection} to GeoDataFrame") - try: - df = apply_projection(df, projection) - except Exception: - raise ValueError("i18nerror.transformation.projection_application_failed") - - return df - - -def apply_transformations( - df: pd.DataFrame, transformation_config: IntegrityTransformation -) -> pd.DataFrame: - """Apply transformations to a GeoDataFrame or a DataFrame. - - Args: - df: Input GeoDataFrame or DataFrame - transformation_config: IntegrityTransformation model containing: - - columns: Optional list of column configurations - - force_projection: Optional ForceProjection with type, x_column, y_column - - Returns: - Transformed GeoDataFrame or DataFrame - """ - logger.info(f"Applying transformations with config: {transformation_config}") - - # Rename and cast columns - # Notes: filter and exclusion are handled upstream at the SQL level - # (read_data_from_postgis with columns param), so excluded columns are - # already absent from the DataFrame at this point. - if transformation_config.columns: - df = rename_columns(df, transformation_config.columns) - df = cast_column_types(df, transformation_config.columns) - - # Handle projection/CRS transformation - y_column = None - x_column = None - projection = None - - # Extract force_projection config - force_projection = transformation_config.force_projection - if force_projection: - y_column = force_projection.y_column - x_column = force_projection.x_column - projection = force_projection.type - - # Convert projection to string if necessary - if isinstance(projection, str): - projection_str = projection - else: - # Priority: 1) Use specified projection, 2) Use existing GeoDataFrame CRS, 3) Default to EPSG:4326 - if projection is not None: - projection_str = str(projection) - if isinstance(df, gpd.GeoDataFrame) and df.crs is not None: - projection_str = df.crs.to_string() - logger.info( - f"No projection specified, keeping existing GeoDataFrame CRS: {projection_str}" - ) - else: - projection_str = DEFAULT_CRS - logger.info(f"No projection specified, defaulting to {DEFAULT_CRS}") - - logger.info(f"Projection set to {projection_str}") - - # Check if DataFrame has a 'geom' column and convert to GeoDataFrame - if not isinstance(df, gpd.GeoDataFrame) and DEFAULT_GEOMETRY_COLUMN in df.columns: - df = _convert_geom_column_to_geodataframe(df, projection_str) - - # Create geometries from columns if specified - # and apply projection transformation - df = _apply_projection_transformation( - df, - projection_str, - x_column=x_column if isinstance(x_column, str) else None, - y_column=y_column if isinstance(y_column, str) else None, - ) - - return df diff --git a/libs/data_manipulation/src/data_manipulation/transformation/transform_columns.py b/libs/data_manipulation/src/data_manipulation/transformation/transform_columns.py deleted file mode 100644 index e177c8f8..00000000 --- a/libs/data_manipulation/src/data_manipulation/transformation/transform_columns.py +++ /dev/null @@ -1,148 +0,0 @@ -"""In-memory column transformation functions: rename and cast. - -These functions operate on a DataFrame *after* it has been fetched from the -database. Filter and exclusion are handled upstream at the SQL level -(see filter_sql.py / read_data_from_postgis), so excluded columns are already -absent from the DataFrame when these functions are called. -""" - -import logging - -import pandas as pd - -from data_manipulation.models import CastType, ColumnConfig - -logger = logging.getLogger(__name__) - -# Common string representations of True/False, matched case-insensitively. -# Used by _parse_bool_from_strings to handle text-encoded boolean columns. -_BOOL_TRUE = frozenset({"true", "1", "yes", "on", "t", "y"}) -_BOOL_FALSE = frozenset({"false", "0", "no", "off", "f", "n"}) - - -def _parse_bool_from_strings(series: pd.Series) -> pd.Series: - """Parse a string-typed Series to nullable boolean. - - pandas ``astype(bool)`` treats **any non-empty string** — including - ``"False"``, ``"0"``, ``"no"`` — as ``True``, which is incorrect for - user-visible column data. This function maps common string - representations to proper booleans and coerces unrecognised values to - ``pd.NA`` (logged as a warning). - - Args: - series: An object-dtype Series containing string boolean values. - - Returns: - A Series with pandas nullable boolean dtype (``"boolean"``). - """ - - def _to_bool(val: object) -> object: - if val is None or (isinstance(val, float) and val != val): - return None - s = str(val).strip().lower() - if s in _BOOL_TRUE: - return True - if s in _BOOL_FALSE: - return False - return None - - result = series.map(_to_bool).astype("boolean") # pyright: ignore[reportUnknownMemberType] - na_count = int(result.isna().sum()) - if na_count > 0: - logger.warning(f"Boolean cast: {na_count} value(s) could not be parsed and were set to NA") - return result - - -def rename_columns( - df: pd.DataFrame, - columns: list[ColumnConfig], -) -> pd.DataFrame: - """Rename columns in the DataFrame according to column configurations. - - Only renames non-excluded columns that have a non-None ``new_name``. - Excluded columns are already absent from the DataFrame (filtered at the SQL - level), so they are safely ignored here. - - Args: - df: Input GeoDataFrame or DataFrame. - columns: Column configurations containing rename instructions. - - Returns: - DataFrame with columns renamed as configured. - """ - rename_map: dict[str, str] = { - col_config.original_name: col_config.new_name - for col_config in columns - if not col_config.excluded and col_config.new_name is not None - } - - if not rename_map: - return df - - logger.info(f"Renaming columns: {rename_map}") - return df.rename(columns=rename_map) - - -def cast_column_types( - df: pd.DataFrame, - columns: list[ColumnConfig], -) -> pd.DataFrame: - """Cast column types according to column configurations. - - Applied in-memory after the data is fetched. Excluded columns are already - absent from the DataFrame. After a rename has been applied, columns are - identified by their *effective* name (``new_name`` if set, else - ``original_name``). - - Cast is optional for preview display (UI renders everything as strings) - but required for the final write in the process DAG. - - Args: - df: Input GeoDataFrame or DataFrame. - columns: Column configurations containing cast instructions. - - Returns: - DataFrame with columns cast to the specified types. - """ - for col_config in columns: - if col_config.excluded or col_config.cast_type is None: - continue - - # After rename the column lives under its effective name - effective_name = ( - col_config.new_name if col_config.new_name is not None else col_config.original_name - ) - - if effective_name not in df.columns: - logger.warning( - f"Column '{effective_name}' not found in DataFrame, skipping cast to " - f"{col_config.cast_type}" - ) - continue - - cast_type = col_config.cast_type - try: - if cast_type == CastType.BOOLEAN: - df = df.copy() - col = df[effective_name] - # Use a custom string parser for object (text) columns so that - # "False", "0", "no" correctly become False rather than True. - # pandas astype(bool) treats any non-empty string as True. - if col.dtype == object: - df[effective_name] = _parse_bool_from_strings(col) # type: ignore[arg-type] - else: - df[effective_name] = col.astype(bool) # type: ignore[union-attr] - elif cast_type == CastType.NUMERIC: - df = df.copy() - df[effective_name] = pd.to_numeric(df[effective_name], errors="coerce") # pyright: ignore[reportUnknownMemberType] - elif cast_type == CastType.TEXT: - df = df.copy() - df[effective_name] = df[effective_name].astype(str) # pyright: ignore[reportUnknownMemberType] - elif cast_type == CastType.DATE: - df = df.copy() - df[effective_name] = pd.to_datetime(df[effective_name], errors="coerce") # type: ignore[arg-type] - logger.info(f"Cast column '{effective_name}' to {cast_type}") - except Exception as e: - logger.warning(f"Failed to cast column '{effective_name}' to {cast_type}: {e}") - - return df diff --git a/libs/data_manipulation/src/data_manipulation/transformation/transform_encoding.py b/libs/data_manipulation/src/data_manipulation/transformation/transform_encoding.py deleted file mode 100644 index 658d8473..00000000 --- a/libs/data_manipulation/src/data_manipulation/transformation/transform_encoding.py +++ /dev/null @@ -1,30 +0,0 @@ -import logging - -import geopandas as gpd -import pandas as pd - -logger = logging.getLogger(__name__) - - -def apply_encoding( - df: gpd.GeoDataFrame | pd.DataFrame, encoding: str -) -> gpd.GeoDataFrame | pd.DataFrame: - """Apply encoding transformation to text columns.""" - try: - text_columns = df.select_dtypes(include=["object"]).columns - - if len(text_columns) > 0: - # Define encoding function to apply - def encode_cell(v: object) -> object: - if isinstance(v, str): - return v.encode("utf-8", errors="ignore").decode(encoding, errors="replace") - return v - - # Apply encoding to all text columns at once using map (DataFrame-wide operation) - df[text_columns] = df[text_columns].map(encode_cell) - logger.info(f"Applied encoding {encoding} to {len(text_columns)} columns") - - except Exception as e: - logger.warning(f"Failed to apply encoding: {e}") - - return df diff --git a/libs/data_manipulation/src/data_manipulation/transformation/transform_geom_point.py b/libs/data_manipulation/src/data_manipulation/transformation/transform_geom_point.py deleted file mode 100644 index 261b55dc..00000000 --- a/libs/data_manipulation/src/data_manipulation/transformation/transform_geom_point.py +++ /dev/null @@ -1,56 +0,0 @@ -import logging - -import geopandas as gpd -import pandas as pd - -logger = logging.getLogger(__name__) - - -def create_geometries_from_columns( - df: gpd.GeoDataFrame | pd.DataFrame, - crs: str, - x_column: str, - y_column: str, -) -> gpd.GeoDataFrame | pd.DataFrame: - """Create Point geometries from X/Y coordinate columns optimized for large datasets. - - Args: - df: Input DataFrame - x_column: X/longitude column name - y_column: Y/latitude column name - crs: Coordinate Reference System (default: EPSG:4326 for WGS84) - batch_size: Size of batches for processing (auto-calculated if None) - max_workers: Maximum threads for parallel processing (default: CPU count) - - Returns: - GeoDataFrame with Point geometries created from coordinates - """ - if not (x_column in df.columns and y_column in df.columns): - raise ValueError(f"Columns {x_column} and/or {y_column} not found in data") - - # Check for non-numeric values in coordinate columns with tolerance - tolerance = 0.8 # 80% of non-numeric values allowed - x_numeric: pd.Series[float] = pd.to_numeric(df[x_column], errors="coerce") # type: ignore[assignment] - y_numeric: pd.Series[float] = pd.to_numeric(df[y_column], errors="coerce") # type: ignore[assignment] - x_nan_ratio = x_numeric.isna().mean() - y_nan_ratio = y_numeric.isna().mean() - - if x_nan_ratio > tolerance or y_nan_ratio > tolerance: - raise ValueError( - f"Too many non-numeric values in columns: {x_column} ({x_nan_ratio:.1%}) - {y_column} ({y_nan_ratio:.1%})." - f"Verify the data or consider preprocessing to clean the coordinate columns." - ) - try: - gdf = gpd.GeoDataFrame( - df, - geometry=gpd.points_from_xy( - x_numeric, # type: ignore[no-any-return] - y_numeric, # type: ignore[no-any-return] - ), - crs=crs, - ) - logger.info(f"Created geometries from columns {x_column}/{y_column} and set CRS to {crs}") - return gdf - except Exception as e: - logger.error(f"Failed to create geometries from columns {x_column}/{y_column}") - raise e diff --git a/libs/data_manipulation/src/data_manipulation/transformation/transform_projection.py b/libs/data_manipulation/src/data_manipulation/transformation/transform_projection.py deleted file mode 100644 index 96511836..00000000 --- a/libs/data_manipulation/src/data_manipulation/transformation/transform_projection.py +++ /dev/null @@ -1,28 +0,0 @@ -import logging - -import geopandas as gpd - -logger = logging.getLogger(__name__) - - -def apply_projection( - gdf: gpd.GeoDataFrame, - projection: str, -) -> gpd.GeoDataFrame: - """Apply CRS/projection to existing geometries in a GeoDataFrame. - - Args: - gdf: Input GeoDataFrame with geometries - projection: Target CRS/projection (e.g., 'EPSG:4326') - - Returns: - GeoDataFrame with projection applied - """ - try: - logger.info(f"Applying projection {projection} to geometries") - gdf.set_crs(projection, inplace=True, allow_override=True) - except Exception as e: - logger.warning(f"Failed to set CRS to {projection}: {e}") - raise e - - return gdf diff --git a/libs/data_manipulation/src/data_manipulation/utils.py b/libs/data_manipulation/src/data_manipulation/utils.py index 33c19f72..b66073f2 100644 --- a/libs/data_manipulation/src/data_manipulation/utils.py +++ b/libs/data_manipulation/src/data_manipulation/utils.py @@ -3,12 +3,9 @@ import logging import re import unicodedata -from typing import Union from urllib.parse import urljoin import requests -from geopandas import GeoDataFrame -from pandas import DataFrame from data_manipulation.constants import PG_IDENTIFIER_MAX_LENGTH from data_manipulation.logging import configure_logging @@ -94,11 +91,6 @@ def resolve_url(url: str) -> str: raise ValueError(f"Error checking URL {url}: {e}") from e -def is_geo_dataframe(df: Union[GeoDataFrame, DataFrame]) -> bool: - """Check if a dataframe is a GeoDataFrame.""" - return isinstance(df, GeoDataFrame) - - def compute_bbox_from_postgis_stextent_string(str_bbox: str) -> dict[str, float]: m = re.match( r"BOX\(\s*([-\d\.eE]+)\s+([-\d\.\.eE]+)\s*,\s*([-\d\.eE]+)\s+([-\d\.eE]+)\s*\)", diff --git a/libs/data_manipulation/tests/test_column_actions.py b/libs/data_manipulation/tests/test_column_actions.py index 21cd62c8..7beb2022 100644 --- a/libs/data_manipulation/tests/test_column_actions.py +++ b/libs/data_manipulation/tests/test_column_actions.py @@ -1,677 +1,111 @@ -"""Unit tests for column action transformations""" +"""Tests for SQL-level column operations (selection and filtering).""" -from unittest.mock import patch +from sqlalchemy import Column, MetaData, Table, Text +from sqlalchemy.dialects import postgresql -import geopandas as gpd -import pandas as pd -from shapely.geometry import Point -from sqlalchemy import ( - BinaryExpression, - Column, - Integer, - MetaData, - Select, - String, - Table, - create_engine, +from data_manipulation.models import ColumnConfig, ColumnFilter, FilterOperator +from data_manipulation.transformation.filter_sql import ( + _escape_like, # type: ignore[reportPrivateUsage] + build_filter_clause, + build_sql_column_ops, ) -from sqlalchemy.sql.elements import Cast - -from data_manipulation.ingestion import read_and_transform_data, read_data_from_postgis -from data_manipulation.models import ( - CastType, - ColumnConfig, - ColumnFilter, - FilterOperator, - ForceProjection, - IntegrityTransformation, -) -from data_manipulation.transformation.filter_sql import build_sql_column_ops -from data_manipulation.transformation.transform import apply_transformations -from data_manipulation.transformation.transform_columns import cast_column_types, rename_columns - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- -def _make_table() -> Table: - """Return an in-memory SQLAlchemy Table without requiring a DB connection.""" - metadata = MetaData() +def _table() -> Table: + metadata = MetaData(schema="staging") return Table( - "test_table", - metadata, - Column("id", Integer), - Column("name", String), - Column("city", String), - Column("age", Integer), - ) - - -def _sqlite_engine_with_data(): - """Return a SQLite in-memory engine with a simple test table pre-populated.""" - engine = create_engine("sqlite://") - metadata = MetaData() - tbl = Table( - "staging", + "places", metadata, - Column("id", Integer), - Column("name", String), - Column("city", String), + Column("name", Text), + Column("city", Text), + Column("secret", Text), ) - metadata.create_all(engine) - rows = [ - {"id": i, "name": f"name_{i}", "city": "Paris" if i > 10 else "Lyon"} for i in range(1, 21) - ] - with engine.connect() as conn: - conn.execute(tbl.insert(), rows) - conn.commit() - return engine +def _compile(clause: object) -> str: + return str(clause.compile(dialect=postgresql.dialect())) # type: ignore[attr-defined] -# =========================================================================== -# Tests for build_sql_column_ops -# =========================================================================== +class TestEscapeLike: + def test_escapes_percent(self) -> None: + assert _escape_like("50%") == "50\\%" + def test_escapes_underscore(self) -> None: + assert _escape_like("a_b") == "a\\_b" -class TestBuildSqlColumnOps: - """Unit tests for build_sql_column_ops.""" + def test_escapes_backslash_first(self) -> None: + assert _escape_like("a\\b") == "a\\\\b" - def test_empty_columns_returns_all_cols_no_where(self): - """Empty column list → (all_cols, []).""" - table = _make_table() - select_cols, where_clauses = build_sql_column_ops([], table) - assert [c.key for c in select_cols] == ["id", "name", "city", "age"] - assert where_clauses == [] - - def test_excluded_column_absent_from_select_cols(self): - """Excluded column not present in select_cols.""" - table = _make_table() - columns = [ - ColumnConfig(original_name="id"), - ColumnConfig(original_name="name", excluded=True), - ColumnConfig(original_name="city"), - ] - select_cols, _ = build_sql_column_ops(columns, table) - - col_keys = [c.key for c in select_cols] - assert "name" not in col_keys - assert "id" in col_keys - assert "city" in col_keys - - def test_no_filter_produces_no_where_clause(self): - """Non-excluded column without filter → no where clause.""" - table = _make_table() - columns = [ColumnConfig(original_name="name")] - - select_cols, where_clauses = build_sql_column_ops(columns, table) - - assert len(select_cols) == 1 - assert where_clauses == [] - - def test_excluded_with_filter_produces_no_where_clause(self): - """excluded=True column with filter → no where clause AND absent from select_cols.""" - table = _make_table() - columns = [ - ColumnConfig( - original_name="name", - excluded=True, - filter=ColumnFilter(operator=FilterOperator.EXACTLY, value="Alice"), - ) - ] - select_cols, where_clauses = build_sql_column_ops(columns, table) - - assert select_cols == [] - assert where_clauses == [] - - def test_exactly_operator_produces_binary_expression(self): - """EXACTLY operator → BinaryExpression with Cast(Text) operand.""" - table = _make_table() - columns = [ - ColumnConfig( - original_name="name", - filter=ColumnFilter(operator=FilterOperator.EXACTLY, value="Alice"), - ) - ] - _, where_clauses = build_sql_column_ops(columns, table) - - assert len(where_clauses) == 1 - expr = where_clauses[0] - assert isinstance(expr, BinaryExpression) - # Left side must be a Cast expression - assert isinstance(expr.left, Cast) - - def test_contains_operator_produces_like_expression(self): - """CONTAINS operator → LIKE BinaryExpression.""" - table = _make_table() - columns = [ - ColumnConfig( - original_name="city", - filter=ColumnFilter(operator=FilterOperator.CONTAINS, value="Par"), - ) - ] - _, where_clauses = build_sql_column_ops(columns, table) - - assert len(where_clauses) == 1 - compiled = where_clauses[0].compile(compile_kwargs={"literal_binds": True}) - assert "LIKE" in str(compiled).upper() - - def test_starts_with_operator_produces_like_expression(self): - """STARTS_WITH operator → LIKE BinaryExpression.""" - table = _make_table() - columns = [ - ColumnConfig( - original_name="name", - filter=ColumnFilter(operator=FilterOperator.STARTS_WITH, value="Al"), - ) - ] - _, where_clauses = build_sql_column_ops(columns, table) - - assert len(where_clauses) == 1 - compiled = where_clauses[0].compile(compile_kwargs={"literal_binds": True}) - assert "LIKE" in str(compiled).upper() - - def test_filter_value_not_inlined_as_literal(self): - """Filter value is a bound parameter, not inlined in SQL text.""" - table = _make_table() - filter_value = "SuperSecretValue" - columns = [ - ColumnConfig( - original_name="name", - filter=ColumnFilter(operator=FilterOperator.EXACTLY, value=filter_value), - ) - ] - _, where_clauses = build_sql_column_ops(columns, table) - - # Compile WITHOUT literal_binds — value must appear as :param placeholder - compiled = where_clauses[0].compile(compile_kwargs={"literal_binds": False}) - sql_text = str(compiled) - assert filter_value not in sql_text, ( - f"Filter value '{filter_value}' was inlined in SQL text: {sql_text}" +class TestBuildFilterClause: + def test_exactly_uses_plain_pattern(self) -> None: + col = _table().c["name"] + clause = build_filter_clause( + col, ColumnFilter(operator=FilterOperator.EXACTLY, value="paris") ) + sql = _compile(clause).upper() + assert "ILIKE" in sql - def test_contains_filter_value_not_inlined(self): - """CONTAINS filter — pattern with % delimiters is a bound param.""" - table = _make_table() - filter_value = "InjectionAttempt" - columns = [ - ColumnConfig( - original_name="city", - filter=ColumnFilter(operator=FilterOperator.CONTAINS, value=filter_value), - ) - ] - _, where_clauses = build_sql_column_ops(columns, table) - - compiled = where_clauses[0].compile(compile_kwargs={"literal_binds": False}) - sql_text = str(compiled) - assert filter_value not in sql_text - - def test_multiple_filters_produce_multiple_where_clauses(self): - """Multiple non-excluded columns with filters → one clause each.""" - table = _make_table() - columns = [ - ColumnConfig( - original_name="name", - filter=ColumnFilter(operator=FilterOperator.STARTS_WITH, value="A"), - ), - ColumnConfig( - original_name="city", - filter=ColumnFilter(operator=FilterOperator.CONTAINS, value="Paris"), - ), - ] - select_cols, where_clauses = build_sql_column_ops(columns, table) - - assert len(select_cols) == 2 - assert len(where_clauses) == 2 - - -# =========================================================================== -# Tests for read_data_from_postgis with columns param -# =========================================================================== - - -class TestReadDataFromPostgisWithColumns: - """Integration-style tests for read_data_from_postgis with columns param.""" - - def test_filter_applied_before_limit(self): - """Rows 11-20 match 'Paris'; with limit=10, all 10 returned rows match.""" - engine = _sqlite_engine_with_data() - columns = [ - ColumnConfig( - original_name="city", - filter=ColumnFilter(operator=FilterOperator.EXACTLY, value="Paris"), - ), - ColumnConfig(original_name="id"), - ColumnConfig(original_name="name"), - ] - - result = read_data_from_postgis("staging", engine, columns=columns, limit=10) - - # All returned rows must match the filter — filter is before LIMIT - assert len(result) == 10 - assert all(result["city"] == "Paris") # type: ignore[reportUnknownArgumentType] - - def test_excluded_column_absent_from_result(self): - """Excluded column is not present in the returned DataFrame.""" - engine = _sqlite_engine_with_data() - columns = [ - ColumnConfig(original_name="id"), - ColumnConfig(original_name="name"), - ColumnConfig(original_name="city", excluded=True), - ] - - result = read_data_from_postgis("staging", engine, columns=columns) - - assert "city" not in result.columns - assert "id" in result.columns - assert "name" in result.columns - - def test_limit_none_returns_all_matching_rows(self): - """limit=None returns all rows matching the filter (no truncation).""" - engine = _sqlite_engine_with_data() - columns = [ - ColumnConfig( - original_name="city", - filter=ColumnFilter(operator=FilterOperator.EXACTLY, value="Paris"), - ), - ColumnConfig(original_name="id"), - ColumnConfig(original_name="name"), - ] - - result = read_data_from_postgis("staging", engine, columns=columns, limit=None) - - assert len(result) == 10 - assert all(result["city"] == "Paris") # type: ignore[reportUnknownArgumentType] - - def test_no_columns_param_returns_all_rows(self): - """No columns param → all rows and columns returned.""" - engine = _sqlite_engine_with_data() - - result = read_data_from_postgis("staging", engine) - - assert len(result) == 20 - assert "id" in result.columns - assert "name" in result.columns - assert "city" in result.columns - - def test_pd_read_sql_receives_select_object(self): - """pd.read_sql receives a Select object, not a compiled string.""" - engine = _sqlite_engine_with_data() - columns = [ColumnConfig(original_name="name"), ColumnConfig(original_name="id")] - - with patch("data_manipulation.ingestion.pd.read_sql") as mock_read_sql: - mock_read_sql.return_value = pd.DataFrame({"name": ["name_1"], "id": [1]}) - read_data_from_postgis("staging", engine, columns=columns) - - assert mock_read_sql.called - first_arg = mock_read_sql.call_args[0][0] - assert isinstance(first_arg, Select), ( - f"Expected Select object but got {type(first_arg).__name__}" + def test_contains_wraps_with_percent(self) -> None: + col = _table().c["name"] + clause = build_filter_clause( + col, ColumnFilter(operator=FilterOperator.CONTAINS, value="par") ) - - def test_all_columns_excluded_returns_empty_dataframe(self): - """Edge case: all columns excluded → returns empty DataFrame.""" - engine = _sqlite_engine_with_data() - columns = [ - ColumnConfig(original_name="id", excluded=True), - ColumnConfig(original_name="name", excluded=True), - ColumnConfig(original_name="city", excluded=True), - ] - - result = read_data_from_postgis("staging", engine, columns=columns) - - assert isinstance(result, pd.DataFrame) - assert len(result) == 0 - - def test_filter_before_limit_confirms_no_pre_limit(self): - """T006a (1 extra): confirm filter-then-limit vs limit-then-filter difference. - - Table has 20 rows; rows 1-10 have city='Lyon', rows 11-20 have city='Paris'. - With limit=5 and filter city='Paris': - - Correct (filter first): returns 5 Paris rows - - Wrong (limit first): limit=5 would capture only Lyon rows → 0 Paris rows - """ - engine = _sqlite_engine_with_data() - columns = [ - ColumnConfig( - original_name="city", - filter=ColumnFilter(operator=FilterOperator.EXACTLY, value="Paris"), - ), - ColumnConfig(original_name="id"), - ColumnConfig(original_name="name"), - ] - - result = read_data_from_postgis("staging", engine, columns=columns, limit=5) - - assert len(result) == 5 - assert all(result["city"] == "Paris"), "limit-then-filter bug: 0 Paris rows returned" # type: ignore[reportUnknownArgumentType] - - -# =========================================================================== -# Tests for read_and_transform_data with column transformations -# =========================================================================== - - -class TestReadAndTransformData: - """Tests for read_and_transform_data pipeline.""" - - def test_config_none_returns_raw_data(self): - """config=None → raw data returned unchanged.""" - engine = _sqlite_engine_with_data() - - result = read_and_transform_data("staging", engine, config=None) - - assert len(result) == 20 - assert list(result.columns) == ["id", "name", "city"] - - def test_end_to_end_filter_exclude_rename_cast(self): - """filter+exclude at SQL, rename+cast at Python — matching manual steps.""" - engine = _sqlite_engine_with_data() - columns = [ - ColumnConfig( - original_name="city", - filter=ColumnFilter(operator=FilterOperator.EXACTLY, value="Paris"), - new_name="ville", - ), - ColumnConfig(original_name="id", cast_type=CastType.TEXT), - ColumnConfig(original_name="name", excluded=True), - ] - config = IntegrityTransformation(columns=columns) - - result = read_and_transform_data("staging", engine, config=config) - - # 10 Paris rows - assert len(result) == 10 - # Excluded column absent - assert "name" not in result.columns - # Renamed column present - assert "ville" in result.columns - assert "city" not in result.columns - # Cast applied: id should be string - assert result["id"].dtype == object or pd.api.types.is_string_dtype(result["id"]) - - def test_limit_10_vs_none_filter_then_limit(self): - """limit=10 vs limit=None confirms LIMIT applied after filters.""" - engine = _sqlite_engine_with_data() - columns = [ - ColumnConfig( - original_name="city", - filter=ColumnFilter(operator=FilterOperator.EXACTLY, value="Paris"), - ), - ColumnConfig(original_name="id"), - ColumnConfig(original_name="name"), - ] - config = IntegrityTransformation(columns=columns) - - result_limited = read_and_transform_data("staging", engine, config=config, limit=10) - result_full = read_and_transform_data("staging", engine, config=config, limit=None) - - assert len(result_limited) == 10 - assert len(result_full) == 10 # Only 10 matching rows exist - assert all(result_limited["city"] == "Paris") # type: ignore[reportUnknownArgumentType] - assert all(result_full["city"] == "Paris") # type: ignore[reportUnknownArgumentType] - - -# =========================================================================== -# Tests for rename_columns and cast_column_types -# =========================================================================== - - -class TestRenameColumns: - """Unit tests for rename_columns.""" - - def test_rename_changes_column_name(self): - """rename_columns renames columns according to new_name.""" - df = pd.DataFrame({"col_a": [1, 2], "col_b": ["x", "y"]}) - columns = [ - ColumnConfig(original_name="col_a", new_name="column_a"), - ColumnConfig(original_name="col_b"), - ] - - result = rename_columns(df, columns) - - assert "column_a" in result.columns - assert "col_a" not in result.columns - assert "col_b" in result.columns # unchanged - - def test_no_rename_when_new_name_is_none(self): - """Columns without new_name are untouched.""" - df = pd.DataFrame({"col_a": [1, 2]}) - columns = [ColumnConfig(original_name="col_a")] - - result = rename_columns(df, columns) - - assert list(result.columns) == ["col_a"] - - def test_excluded_columns_already_absent_no_error(self): - """Excluded columns (already absent from DataFrame) don't cause rename errors.""" - df = pd.DataFrame({"col_a": [1, 2]}) - columns = [ - ColumnConfig(original_name="col_excluded", excluded=True, new_name="should_not_appear"), - ColumnConfig(original_name="col_a", new_name="alpha"), - ] - - result = rename_columns(df, columns) - - assert "alpha" in result.columns - assert "should_not_appear" not in result.columns - - def test_multiple_renames(self): - """Multiple columns renamed in a single call.""" - df = pd.DataFrame({"a": [1], "b": [2], "c": [3]}) - columns = [ - ColumnConfig(original_name="a", new_name="alpha"), - ColumnConfig(original_name="b", new_name="beta"), - ColumnConfig(original_name="c"), - ] - - result = rename_columns(df, columns) - - assert sorted(result.columns.tolist()) == ["alpha", "beta", "c"] # type: ignore[reportUnknownArgumentType] - - -class TestCastColumnTypes: - """Unit tests for cast_column_types.""" - - def test_cast_to_text(self): - """CastType.TEXT: numeric column converted to string dtype.""" - df = pd.DataFrame({"num": [1, 2, 3]}) - columns = [ColumnConfig(original_name="num", cast_type=CastType.TEXT)] - - result = cast_column_types(df, columns) - - assert pd.api.types.is_string_dtype(result["num"]) or result["num"].dtype == object - - def test_cast_to_numeric(self): - """CastType.NUMERIC: string column converted to numeric.""" - df = pd.DataFrame({"val": ["1.5", "2.0", "not_a_number"]}) - columns = [ColumnConfig(original_name="val", cast_type=CastType.NUMERIC)] - - result = cast_column_types(df, columns) - - assert pd.api.types.is_numeric_dtype(result["val"]) - # "not_a_number" should become NaN - assert pd.isna(result["val"].iloc[2]) # type: ignore[reportUnknownArgumentType] - - def test_cast_to_boolean(self): - """CastType.BOOLEAN: numeric column cast to bool dtype (astype path).""" - df = pd.DataFrame({"flag": [1, 0, 1]}) - columns = [ColumnConfig(original_name="flag", cast_type=CastType.BOOLEAN)] - - result = cast_column_types(df, columns) - - assert result["flag"].dtype == bool - - def test_cast_to_boolean_string_false_not_true(self): - """CastType.BOOLEAN: 'False', '0', 'no' must NOT become True. - - pandas astype(bool) treats any non-empty string as True, which is - incorrect for user-visible boolean columns. The custom string parser - must correctly map 'False' → False and '0' → False. - """ - df = pd.DataFrame({"flag": ["True", "False", "1", "0", "yes", "no"]}) - columns = [ColumnConfig(original_name="flag", cast_type=CastType.BOOLEAN)] - - result = cast_column_types(df, columns) - - assert result["flag"].iloc[0] == True # noqa: E712 - assert result["flag"].iloc[1] == False # noqa: E712 - assert result["flag"].iloc[2] == True # noqa: E712 - assert result["flag"].iloc[3] == False # noqa: E712 - assert result["flag"].iloc[4] == True # noqa: E712 - assert result["flag"].iloc[5] == False # noqa: E712 - - def test_cast_to_boolean_unrecognised_string_becomes_na(self): - """CastType.BOOLEAN: unrecognised strings (e.g. 'maybe') become NA.""" - df = pd.DataFrame({"flag": ["True", "maybe", "False"]}) - columns = [ColumnConfig(original_name="flag", cast_type=CastType.BOOLEAN)] - - result = cast_column_types(df, columns) - - assert pd.isna(result["flag"].iloc[1]) - - def test_cast_to_date(self): - """CastType.DATE: string column parsed to datetime.""" - df = pd.DataFrame({"dt": ["2024-01-01", "2024-06-15", "invalid"]}) - columns = [ColumnConfig(original_name="dt", cast_type=CastType.DATE)] - - result = cast_column_types(df, columns) - - assert pd.api.types.is_datetime64_any_dtype(result["dt"]) - # Invalid date → NaT - assert pd.isna(result["dt"].iloc[2]) # type: ignore[reportUnknownArgumentType] - - def test_cast_type_none_leaves_column_unchanged(self): - """cast_type=None: column dtype is not modified.""" - df = pd.DataFrame({"val": [1, 2, 3]}) - original_dtype = df["val"].dtype - columns = [ColumnConfig(original_name="val")] - - result = cast_column_types(df, columns) - - assert result["val"].dtype == original_dtype - - def test_uses_effective_name_after_rename(self): - """cast_column_types looks up by effective name (new_name if set).""" - df = pd.DataFrame({"renamed_col": ["1", "2", "3"]}) - columns = [ - ColumnConfig( - original_name="original_col", new_name="renamed_col", cast_type=CastType.NUMERIC - ) - ] - - result = cast_column_types(df, columns) - - assert pd.api.types.is_numeric_dtype(result["renamed_col"]) - - def test_excluded_column_skipped(self): - """Excluded columns (cast_type set but excluded=True) are skipped.""" - df = pd.DataFrame({"col": ["1", "2"]}) - original_dtype = df["col"].dtype - columns = [ColumnConfig(original_name="col", cast_type=CastType.NUMERIC, excluded=True)] - - result = cast_column_types(df, columns) - - assert result["col"].dtype == original_dtype - - -# =========================================================================== -# Tests for updated apply_transformations (rename + cast only) -# =========================================================================== - - -class TestApplyTransformations: - def test_empty_config_returns_unchanged_data(self): - df = pd.DataFrame({"col1": [1, 2, 3], "col2": ["a", "b", "c"]}) - - result = apply_transformations(df, IntegrityTransformation()) - - assert isinstance(result, pd.DataFrame) - assert len(result) == 3 - assert list(result.columns) == ["col1", "col2"] - - def test_backward_compat_columns_none(self): - df = pd.DataFrame({"col1": [1, 2], "col2": ["x", "y"]}) - config = IntegrityTransformation(columns=None) - - result = apply_transformations(df, config) - - assert list(result.columns) == ["col1", "col2"] - assert len(result) == 2 - - def test_rename_applied_via_apply_transformations(self): - """columns with new_name → rename applied.""" - df = pd.DataFrame({"original": [1, 2]}) - config = IntegrityTransformation( - columns=[ColumnConfig(original_name="original", new_name="renamed")] + # The bound value should be %par% — inspect the bound parameters + params = clause.compile(dialect=postgresql.dialect()).params # type: ignore[attr-defined] + assert any(v == "%par%" for v in params.values()) + + def test_starts_with_appends_percent(self) -> None: + col = _table().c["name"] + clause = build_filter_clause( + col, ColumnFilter(operator=FilterOperator.STARTS_WITH, value="par") ) + params = clause.compile(dialect=postgresql.dialect()).params # type: ignore[attr-defined] + assert any(v == "par%" for v in params.values()) - result = apply_transformations(df, config) - - assert "renamed" in result.columns - assert "original" not in result.columns - - def test_cast_applied_via_apply_transformations(self): - """columns with cast_type → cast applied.""" - df = pd.DataFrame({"num": ["1", "2", "3"]}) - config = IntegrityTransformation( - columns=[ColumnConfig(original_name="num", cast_type=CastType.NUMERIC)] + def test_value_is_escaped_in_bound_param(self) -> None: + col = _table().c["name"] + clause = build_filter_clause( + col, ColumnFilter(operator=FilterOperator.CONTAINS, value="50%") ) + params = clause.compile(dialect=postgresql.dialect()).params # type: ignore[attr-defined] + assert any("50\\%" in str(v) for v in params.values()) - result = apply_transformations(df, config) - - assert pd.api.types.is_numeric_dtype(result["num"]) - def test_excluded_columns_not_present_are_silently_ignored(self): - """Excluded columns absent from DataFrame (SQL-level) → no KeyError.""" - # Simulates the state after read_data_from_postgis filtered out 'secret_col' - df = pd.DataFrame({"visible_col": [1, 2]}) - config = IntegrityTransformation( - columns=[ - ColumnConfig(original_name="secret_col", excluded=True), - ColumnConfig(original_name="visible_col", new_name="shown"), - ] - ) - - result = apply_transformations(df, config) - - assert "shown" in result.columns - assert "secret_col" not in result.columns - assert "visible_col" not in result.columns +class TestBuildSqlColumnOps: + def test_empty_columns_returns_all(self) -> None: + table = _table() + select_cols, where_clauses = build_sql_column_ops([], table) + assert len(select_cols) == len(table.c) + assert where_clauses == [] - def test_projection_still_applied(self): - """Projection logic preserved after refactor.""" - gdf = gpd.GeoDataFrame( - {"name": ["A", "B"]}, geometry=[Point(0, 0), Point(1, 1)], crs="EPSG:4326" + def test_excluded_column_omitted(self) -> None: + table = _table() + select_cols, _ = build_sql_column_ops( + [ + ColumnConfig(original_name="name"), + ColumnConfig(original_name="secret", excluded=True), + ], + table, ) - config = IntegrityTransformation(force_projection=ForceProjection(type="EPSG:3857")) + names = [c.name for c in select_cols] + assert "name" in names + assert "secret" not in names - result = apply_transformations(gdf, config) - - assert isinstance(result, gpd.GeoDataFrame) - assert result.crs.to_string() == "EPSG:3857" # type: ignore[misc] + def test_unknown_column_skipped(self) -> None: + table = _table() + select_cols, _ = build_sql_column_ops([ColumnConfig(original_name="does_not_exist")], table) + assert select_cols == [] - def test_rename_then_cast_then_projection(self): - """rename → cast → projection executed in correct order.""" - gdf = gpd.GeoDataFrame( - {"original": ["1", "2"], "name": ["A", "B"]}, - geometry=[Point(0, 0), Point(1, 1)], - crs="EPSG:4326", - ) - config = IntegrityTransformation( - columns=[ + def test_filter_produces_where_clause(self) -> None: + table = _table() + _, where_clauses = build_sql_column_ops( + [ ColumnConfig( - original_name="original", new_name="renamed", cast_type=CastType.NUMERIC + original_name="city", + filter=ColumnFilter(operator=FilterOperator.EXACTLY, value="paris"), ) ], - force_projection=ForceProjection(type="EPSG:3857"), + table, ) - - result = apply_transformations(gdf, config) - - assert "renamed" in result.columns - assert pd.api.types.is_numeric_dtype(result["renamed"]) - assert result.crs.to_string() == "EPSG:3857" # type: ignore[misc] + assert len(where_clauses) == 1 diff --git a/libs/data_manipulation/tests/test_ingestion.py b/libs/data_manipulation/tests/test_ingestion.py index fb9bfe02..9432ed6c 100644 --- a/libs/data_manipulation/tests/test_ingestion.py +++ b/libs/data_manipulation/tests/test_ingestion.py @@ -1,1178 +1,189 @@ -"""Tests for data ingestion utilities in data_manipulation library.""" +"""Tests for ogr2ogr-based ingestion. -from unittest.mock import MagicMock, Mock, patch -from urllib.error import URLError +``ogr2ogr``/GDAL is not installed in the unit-test environment, so every test +mocks ``subprocess.run`` (and the network helpers) and asserts on the command +that *would* be executed. Full integration runs in the Docker image. +""" + +import subprocess +from unittest.mock import MagicMock, patch -import geopandas as gpd import pytest -import requests -from geopandas import GeoDataFrame -from pandas import DataFrame -from shapely.geometry import Point +from sqlalchemy import create_engine from sqlalchemy.engine import Engine -from data_manipulation import IntegrityTransformation, apply_transformations -from data_manipulation.constants import POSTGIS_TABLE_NAME_MAX_LENGTH from data_manipulation.ingestion import ( - CHUNK_SIZE, - _read_file_encoded, # pyright: ignore[reportPrivateUsage] + _build_pg_connection_string, # type: ignore[reportPrivateUsage] + _normalize_oapif_url, # type: ignore[reportPrivateUsage] ingest_data_from_database_into_postgis, - ingest_data_from_file_into_postgis, ingest_data_from_ftp_into_postgis, ingest_data_from_ogc_service_into_postgis, - ingest_data_from_url_into_postgis, - read_data_from_postgis, - write_data_to_postgis, + ingest_file_with_ogr2ogr, ) -class TestReadFileEncodedParquet: - """Parquet/GeoParquet dispatch in _read_file_encoded.""" - - @patch("data_manipulation.ingestion.gpd.read_parquet") - def test_geoparquet_returns_geodataframe(self, mock_read_parquet: Mock) -> None: - mock_gdf = GeoDataFrame({"col1": [1], "geometry": [Point(0, 0)]}) - mock_read_parquet.return_value = mock_gdf - - result = _read_file_encoded("test.geoparquet") - - mock_read_parquet.assert_called_once_with("test.geoparquet") - assert result is mock_gdf - - @patch("data_manipulation.ingestion.pd.read_parquet") - @patch("data_manipulation.ingestion.gpd.read_parquet") - def test_plain_parquet_falls_back_to_pandas( - self, mock_gpd_read_parquet: Mock, mock_pd_read_parquet: Mock - ) -> None: - mock_gpd_read_parquet.side_effect = ValueError("no geo metadata") - mock_df = DataFrame({"col1": [1, 2]}) - mock_pd_read_parquet.return_value = mock_df - - result = _read_file_encoded("test.parquet") - - mock_gpd_read_parquet.assert_called_once_with("test.parquet") - mock_pd_read_parquet.assert_called_once_with("test.parquet") - assert result is mock_df - - @patch("data_manipulation.ingestion.gpd.read_parquet") - def test_parquet_with_geo_metadata_returns_geodataframe(self, mock_read_parquet: Mock) -> None: - mock_gdf = GeoDataFrame({"col1": [1], "geometry": [Point(0, 0)]}) - mock_read_parquet.return_value = mock_gdf - - result = _read_file_encoded("test.parquet") - - mock_read_parquet.assert_called_once_with("test.parquet") - assert result is mock_gdf - - -class TestIngestDataFromFileIntoPostgis: - """Test cases for ingest_data_from_file_into_postgis function.""" - - @pytest.fixture - def mock_engine(self) -> Mock: - """Create a mock SQLAlchemy engine.""" - return Mock(spec=Engine) - - @patch("data_manipulation.ingestion.ingest_data_from_url_into_postgis") - def test_ingest_delegates_to_url_function( - self, - mock_ingest_url: Mock, - mock_engine: Mock, - ) -> None: - """A local file path is delegated to the URL ingestion function.""" - ingest_data_from_file_into_postgis("test.geojson", "test_table", mock_engine, "public") - - mock_ingest_url.assert_called_once_with("test.geojson", "test_table", mock_engine, "public") - - @patch("data_manipulation.ingestion.ingest_data_from_url_into_postgis") - def test_ingest_propagates_errors( - self, - mock_ingest_url: Mock, - mock_engine: Mock, - ) -> None: - """Errors raised while ingesting are propagated to the caller.""" - mock_ingest_url.side_effect = Exception("boom") - - with pytest.raises(Exception, match="boom"): - ingest_data_from_file_into_postgis("test.geojson", "test_table", mock_engine, "public") - - @patch("data_manipulation.ingestion.gpd.read_file") - def test_read_file_encoded_utf8_success(self, mock_read_file: Mock) -> None: - """The first chunk is read without an explicit encoding (UTF-8 default).""" - mock_gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - mock_read_file.return_value = mock_gdf - - result = _read_file_encoded("test.geojson") - - mock_read_file.assert_called_once_with("test.geojson", rows=slice(0, CHUNK_SIZE, None)) - assert result is mock_gdf - - @patch("data_manipulation.ingestion.gpd.read_file") - @patch("data_manipulation.ingestion._detect_file_encoding") - def test_read_file_encoded_falls_back_to_detected_encoding( - self, - mock_detect_encoding: Mock, - mock_read_file: Mock, - ) -> None: - """On a UnicodeDecodeError the detected encoding is used for a second attempt.""" - mock_detect_encoding.return_value = "latin-1" - mock_gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - mock_read_file.side_effect = [UnicodeDecodeError("utf-8", b"", 0, 1, ""), mock_gdf] - - result = _read_file_encoded("test.shp") - - assert mock_read_file.call_count == 2 - mock_read_file.assert_any_call("test.shp", rows=slice(0, CHUNK_SIZE, None)) - mock_read_file.assert_any_call( - "test.shp", rows=slice(0, CHUNK_SIZE, None), encoding="latin-1" - ) - mock_detect_encoding.assert_called_once_with("test.shp") - assert result is mock_gdf - - -class TestIngestDataFromUrlIntoPostgis: - """Test cases for ingest_data_from_url_into_postgis function.""" - - @pytest.fixture - def mock_engine(self) -> Mock: - """Create a mock SQLAlchemy engine.""" - return Mock(spec=Engine) - - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion.gpd.read_file") - @patch("data_manipulation.ingestion.requests.get") - def test_ingest_from_url_success( - self, - mock_requests_get: Mock, - mock_read_file: Mock, - mock_write_data: Mock, - mock_engine: Mock, - ) -> None: - """Test successful ingestion from URL.""" - - # Mock the HTTP response - mock_response = Mock() - mock_response.status_code = 200 - mock_response.content = b"test data" - mock_response.headers = {} - mock_requests_get.return_value = mock_response - - # Mock the GeoDataFrame - mock_gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - mock_read_file.return_value = mock_gdf - - ingest_data_from_url_into_postgis( - "http://example.com/data.geojson", "test_table", mock_engine, "public" - ) - - mock_requests_get.assert_called_once_with( - "http://example.com/data.geojson", auth=None, timeout=300 - ) - mock_read_file.assert_called_once() - mock_write_data.assert_called_once_with( - mock_gdf, "test_table", mock_engine, "public", if_exists="replace" - ) - - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion.gpd.read_file") - @patch("data_manipulation.ingestion.requests.get") - def test_ingest_from_url_with_auth( - self, - mock_requests_get: Mock, - mock_read_file: Mock, - mock_write_data: Mock, - mock_engine: Mock, - ) -> None: - """Test ingestion from URL with authentication.""" - - # Mock the HTTP response - mock_response = Mock() - mock_response.status_code = 200 - mock_response.content = b"test data" - mock_response.headers = {} - mock_requests_get.return_value = mock_response - - # Mock the GeoDataFrame - mock_gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - mock_read_file.return_value = mock_gdf - - auth = ("username", "password") - ingest_data_from_url_into_postgis( - "http://example.com/data.geojson", "test_table", mock_engine, "public", auth=auth - ) - - mock_requests_get.assert_called_once_with( - "http://example.com/data.geojson", auth=auth, timeout=300 - ) +@pytest.fixture +def engine() -> Engine: + # Engine creation does not open a connection; safe to use a fake URL. + return create_engine("postgresql://user:secret@dbhost:5432/datadb") - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion.gpd.read_file") - @patch("data_manipulation.ingestion.requests.get") - def test_ingest_from_url_with_content_disposition( - self, - mock_requests_get: Mock, - mock_read_file: Mock, - mock_write_data: Mock, - mock_engine: Mock, - ) -> None: - """Test ingestion from URL extracts filename from Content-Disposition.""" - - # Mock the HTTP response with Content-Disposition header - mock_response = Mock() - mock_response.status_code = 200 - mock_response.content = b"test data" - mock_response.headers = {"Content-Disposition": 'attachment; filename="data.geojson"'} - mock_requests_get.return_value = mock_response - # Mock the GeoDataFrame - mock_gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - mock_read_file.return_value = mock_gdf +@pytest.fixture +def source_engine() -> Engine: + return create_engine("postgresql://srcuser:srcpass@srchost:5433/srcdb") - ingest_data_from_url_into_postgis( - "http://example.com/download", "test_table", mock_engine, "public" - ) - mock_requests_get.assert_called_once() - mock_read_file.assert_called_once() - # Verify that the file was saved with the extracted filename - call_args = mock_read_file.call_args[0][0] - assert "data.geojson" in str(call_args) +def _completed() -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=["ogr2ogr"], returncode=0, stdout="", stderr="") - @patch("data_manipulation.ingestion.requests.get") - def test_ingest_from_url_http_error( - self, - mock_requests_get: Mock, - mock_engine: Mock, - ) -> None: - """Test ingestion from URL raises exception on HTTP error.""" - mock_requests_get.side_effect = requests.exceptions.HTTPError("404 Not Found") +class TestPgConnectionString: + def test_contains_all_parts(self, engine: Engine) -> None: + conn = _build_pg_connection_string(engine) + assert conn.startswith("PG:") + assert "host=dbhost" in conn + assert "port=5432" in conn + assert "dbname=datadb" in conn + assert "user=user" in conn + assert "password=secret" in conn - with pytest.raises(requests.exceptions.HTTPError): - ingest_data_from_url_into_postgis( - "http://example.com/data.geojson", "test_table", mock_engine, "public" - ) - @patch("data_manipulation.ingestion.requests.get") - def test_ingest_from_url_connection_error( - self, - mock_requests_get: Mock, - mock_engine: Mock, - ) -> None: - """Test ingestion from URL raises exception on connection error.""" +class TestNormalizeOapifUrl: + def test_strips_collections_suffix(self) -> None: + assert _normalize_oapif_url("https://x/ogcapi/collections/buildings") == "https://x/ogcapi" - mock_requests_get.side_effect = requests.exceptions.ConnectionError("Connection failed") + def test_strips_trailing_collections(self) -> None: + assert _normalize_oapif_url("https://x/ogcapi/collections") == "https://x/ogcapi" - with pytest.raises(requests.exceptions.ConnectionError): - ingest_data_from_url_into_postgis( - "http://example.com/data.geojson", "test_table", mock_engine, "public" - ) + def test_leaves_plain_root_untouched(self) -> None: + assert _normalize_oapif_url("https://x/ogcapi") == "https://x/ogcapi" - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion.gpd.read_parquet") - @patch("data_manipulation.ingestion.requests.get") - def test_ingest_parquet_url_by_extension( - self, - mock_requests_get: Mock, - mock_read_parquet: Mock, - mock_write_data: Mock, - mock_engine: Mock, - ) -> None: - mock_response = Mock() - mock_response.content = b"parquet bytes" - mock_response.headers = {} - mock_requests_get.return_value = mock_response - mock_gdf = GeoDataFrame({"col1": [1]}, geometry=gpd.GeoSeries([Point(0, 0)])) - mock_read_parquet.return_value = mock_gdf - ingest_data_from_url_into_postgis( - "http://example.com/layer.parquet", "test_table", mock_engine, "public" - ) +class TestIngestFileWithOgr2ogr: + @patch("data_manipulation.ingestion.subprocess.run") + def test_builds_expected_command(self, mock_run: MagicMock, engine: Engine) -> None: + mock_run.return_value = _completed() + ingest_file_with_ogr2ogr("/tmp/data.geojson", "places", engine, schema="staging") - mock_read_parquet.assert_called_once() - path_arg = mock_read_parquet.call_args.args[0] - assert path_arg.endswith(".parquet") - mock_write_data.assert_called_once_with( - mock_gdf, "test_table", mock_engine, "public", if_exists="replace" - ) + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert cmd[0] == "ogr2ogr" + assert "-f" in cmd and "PostgreSQL" in cmd + assert "/tmp/data.geojson" in cmd + assert "staging.places" in cmd + assert "-overwrite" in cmd + assert "GEOMETRY_NAME=geom" in cmd - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion.gpd.read_parquet") - @patch("data_manipulation.ingestion.requests.get") - def test_ingest_geoparquet_url_by_extension( - self, - mock_requests_get: Mock, - mock_read_parquet: Mock, - mock_write_data: Mock, - mock_engine: Mock, - ) -> None: - mock_response = Mock() - mock_response.content = b"geoparquet bytes" - mock_response.headers = {} - mock_requests_get.return_value = mock_response - mock_gdf = GeoDataFrame({"col1": [1]}, geometry=gpd.GeoSeries([Point(0, 0)])) - mock_read_parquet.return_value = mock_gdf + @patch("data_manipulation.ingestion.subprocess.run") + def test_missing_binary_raises_clean_error(self, mock_run: MagicMock, engine: Engine) -> None: + mock_run.side_effect = FileNotFoundError() + with pytest.raises(Exception, match="ogr2ogr"): + ingest_file_with_ogr2ogr("/tmp/data.geojson", "places", engine) - ingest_data_from_url_into_postgis( - "http://example.com/layer.geoparquet", "test_table", mock_engine, "public" + @patch("data_manipulation.ingestion.subprocess.run") + def test_ogr_failure_surfaces_stderr(self, mock_run: MagicMock, engine: Engine) -> None: + mock_run.side_effect = subprocess.CalledProcessError( + returncode=1, cmd=["ogr2ogr"], stderr="bad data" ) + with pytest.raises(Exception, match="bad data"): + ingest_file_with_ogr2ogr("/tmp/data.geojson", "places", engine) - mock_read_parquet.assert_called_once() - path_arg = mock_read_parquet.call_args.args[0] - assert path_arg.endswith(".geoparquet") - mock_write_data.assert_called_once_with( - mock_gdf, "test_table", mock_engine, "public", if_exists="replace" - ) - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion.gpd.read_parquet") - @patch("data_manipulation.ingestion.requests.get") - def test_ingest_parquet_url_with_content_disposition( - self, - mock_requests_get: Mock, - mock_read_parquet: Mock, - mock_write_data: Mock, - mock_engine: Mock, +class TestIngestFromDatabase: + @patch("data_manipulation.ingestion.subprocess.run") + def test_streams_pg_to_pg( + self, mock_run: MagicMock, engine: Engine, source_engine: Engine ) -> None: - mock_response = Mock() - mock_response.content = b"parquet bytes" - mock_response.headers = {"Content-Disposition": 'attachment; filename="export.parquet"'} - mock_requests_get.return_value = mock_response - mock_gdf = GeoDataFrame({"col1": [1]}, geometry=gpd.GeoSeries([Point(0, 0)])) - mock_read_parquet.return_value = mock_gdf - - ingest_data_from_url_into_postgis( - "http://example.com/download/42", "test_table", mock_engine, "public" - ) - - mock_read_parquet.assert_called_once() - path_arg = mock_read_parquet.call_args.args[0] - assert path_arg.endswith("export.parquet") - mock_write_data.assert_called_once_with( - mock_gdf, "test_table", mock_engine, "public", if_exists="replace" - ) - - -class TestIngestDataFromFtpIntoPostgis: - """Test cases for ingest_data_from_ftp_into_postgis function.""" - - @pytest.fixture - def mock_engine(self) -> Mock: - """Create a mock SQLAlchemy engine.""" - return Mock(spec=Engine) - - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion._read_file_encoded") - @patch("data_manipulation.ingestion.urlretrieve") - def test_ingest_from_ftp_success( - self, - mock_urlretrieve: Mock, - mock_read_file: Mock, - mock_write_data: Mock, - mock_engine: Mock, - ) -> None: - """Test successful ingestion from FTP.""" - - # Mock the GeoDataFrame - mock_gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - mock_read_file.return_value = mock_gdf - - ingest_data_from_ftp_into_postgis( - "ftp://example.com/data.geojson", "test_table", mock_engine, "public" - ) - - mock_urlretrieve.assert_called_once() - # Verify URL without auth is passed - assert "ftp://example.com/data.geojson" in str(mock_urlretrieve.call_args[0][0]) - mock_read_file.assert_called_once() - mock_write_data.assert_called_once_with( - mock_gdf, "test_table", mock_engine, "public", if_exists="replace" - ) - - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion._read_file_encoded") - @patch("data_manipulation.ingestion.urlretrieve") - def test_ingest_from_ftp_with_auth( - self, - mock_urlretrieve: Mock, - mock_read_file: Mock, - mock_write_data: Mock, - mock_engine: Mock, - ) -> None: - """Test ingestion from FTP with authentication.""" - - # Mock the GeoDataFrame - mock_gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - mock_read_file.return_value = mock_gdf - - auth = ("username", "password") - ingest_data_from_ftp_into_postgis( - "ftp://example.com/data.geojson", "test_table", mock_engine, "public", auth=auth - ) - - mock_urlretrieve.assert_called_once() - # Verify credentials are encoded in URL - called_url = str(mock_urlretrieve.call_args[0][0]) - assert "username" in called_url - assert "password" in called_url - assert "@example.com" in called_url - - @patch("data_manipulation.ingestion.urlretrieve") - def test_ingest_from_ftp_auth_failed( - self, - mock_urlretrieve: Mock, - mock_engine: Mock, - ) -> None: - """Test ingestion from FTP raises exception on authentication failure.""" - mock_urlretrieve.side_effect = URLError("530 Login incorrect") - - with pytest.raises(Exception, match="FTP authentication failed"): - ingest_data_from_ftp_into_postgis( - "ftp://example.com/data.geojson", "test_table", mock_engine, "public" - ) - - @patch("data_manipulation.ingestion.urlretrieve") - def test_ingest_from_ftp_file_not_found( - self, - mock_urlretrieve: Mock, - mock_engine: Mock, - ) -> None: - """Test ingestion from FTP raises exception when file not found.""" - mock_urlretrieve.side_effect = URLError("550 No such file") - - with pytest.raises(Exception, match="FTP file not found"): - ingest_data_from_ftp_into_postgis( - "ftp://example.com/data.geojson", "test_table", mock_engine, "public" - ) - - @patch("data_manipulation.ingestion.urlretrieve") - def test_ingest_from_ftp_timeout( - self, - mock_urlretrieve: Mock, - mock_engine: Mock, - ) -> None: - """Test ingestion from FTP raises exception on connection timeout.""" - mock_urlretrieve.side_effect = URLError("Connection timed out") - - with pytest.raises(Exception, match="FTP connection timeout"): - ingest_data_from_ftp_into_postgis( - "ftp://example.com/data.geojson", "test_table", mock_engine, "public" - ) - - @patch("data_manipulation.ingestion.urlretrieve") - def test_ingest_from_ftp_connection_refused( - self, - mock_urlretrieve: Mock, - mock_engine: Mock, - ) -> None: - """Test ingestion from FTP raises exception when connection is refused.""" - mock_urlretrieve.side_effect = URLError("Connection refused") - - with pytest.raises(Exception, match="FTP connection refused"): - ingest_data_from_ftp_into_postgis( - "ftp://example.com/data.geojson", "test_table", mock_engine, "public" - ) - - @patch("data_manipulation.ingestion.urlretrieve") - def test_ingest_from_ftp_network_error( - self, - mock_urlretrieve: Mock, - mock_engine: Mock, - ) -> None: - """Test ingestion from FTP raises exception on network error.""" - - mock_urlretrieve.side_effect = OSError("Network unreachable") - - with pytest.raises(Exception, match="Network error"): - ingest_data_from_ftp_into_postgis( - "ftp://example.com/data.geojson", "test_table", mock_engine, "public" - ) - - -class TestReadDataFromPostgis: - """Test cases for read_data_from_postgis function.""" - - @pytest.fixture - def mock_engine(self) -> Mock: - """Create a mock SQLAlchemy engine.""" - return Mock(spec=Engine) - - @patch("data_manipulation.ingestion.gpd.read_postgis") - @patch("data_manipulation.ingestion.select") - @patch("data_manipulation.ingestion.Table") - @patch("data_manipulation.ingestion.MetaData") - def test_read_data_success_with_geometry( - self, - mock_metadata_class: Mock, - mock_table_class: Mock, - mock_select: Mock, - mock_read_postgis: Mock, - mock_engine: Mock, - ) -> None: - """Test successful data read from PostGIS with geometry column.""" - - # Mock the metadata and table - mock_metadata = MagicMock() - mock_metadata_class.return_value = mock_metadata - mock_table = MagicMock() - # Mock table.c to have the 'geom' column - mock_column = MagicMock() - mock_column.name = "geom" - mock_table.c = {"geom": mock_column, "col1": MagicMock()} - mock_table_class.return_value = mock_table - - # Mock the select query - mock_query = MagicMock() - mock_select.return_value = mock_query - mock_compiled = MagicMock() - mock_compiled.__str__ = MagicMock(return_value="SELECT * FROM test_table") - mock_query.compile.return_value = mock_compiled - - mock_gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - mock_read_postgis.return_value = mock_gdf - - result = read_data_from_postgis("test_table", mock_engine, "public") - - assert isinstance(result, GeoDataFrame) - assert len(result) == 2 - mock_read_postgis.assert_called_once() - mock_metadata_class.assert_called_once_with(schema="public") - mock_table_class.assert_called_once_with( - "test_table", mock_metadata, autoload_with=mock_engine - ) - mock_select.assert_called_once_with(mock_table) - - @patch("data_manipulation.ingestion.pd.read_sql") - @patch("data_manipulation.ingestion.select") - @patch("data_manipulation.ingestion.Table") - @patch("data_manipulation.ingestion.MetaData") - def test_read_data_success_without_geometry( - self, - mock_metadata_class: Mock, - mock_table_class: Mock, - mock_select: Mock, - mock_read_sql: Mock, - mock_engine: Mock, - ) -> None: - """Test successful data read from PostGIS without geometry column.""" - - # Mock the metadata and table - mock_metadata = MagicMock() - mock_metadata_class.return_value = mock_metadata - mock_table = MagicMock() - # Mock table.c to NOT have the 'geom' column - mock_table.c = {"col1": MagicMock(), "col2": MagicMock()} - mock_table_class.return_value = mock_table - - # Mock the select query - mock_query = MagicMock() - mock_select.return_value = mock_query - mock_compiled = MagicMock() - mock_compiled.__str__ = MagicMock(return_value="SELECT * FROM test_table") - mock_query.compile.return_value = mock_compiled - - mock_df = DataFrame({"col1": [1, 2], "col2": ["a", "b"]}) - mock_read_sql.return_value = mock_df - - result = read_data_from_postgis("test_table", mock_engine, "public") - - assert isinstance(result, DataFrame) - assert len(result) == 2 - mock_read_sql.assert_called_once() - mock_metadata_class.assert_called_once_with(schema="public") - mock_table_class.assert_called_once_with( - "test_table", mock_metadata, autoload_with=mock_engine - ) - mock_select.assert_called_once_with(mock_table) - - @patch("data_manipulation.ingestion.gpd.read_postgis") - def test_read_data_validates_table_name( - self, - mock_read_postgis: Mock, - mock_engine: Mock, - ) -> None: - """Test that read_data validates table name.""" - - with pytest.raises(ValueError): - read_data_from_postgis("invalid-table-name!", mock_engine, "public") - - mock_read_postgis.assert_not_called() - - @patch("data_manipulation.ingestion.gpd.read_postgis") - def test_read_data_sql_injection_prevented( - self, - mock_read_postgis: Mock, - mock_engine: Mock, - ) -> None: - """Test that SQL injection attempts are prevented.""" - - malicious_names = [ - "table; DROP TABLE users--", - "table' OR '1'='1", - 'table" DROP SCHEMA public CASCADE--', - ] - - for malicious_name in malicious_names: - with pytest.raises(ValueError): - read_data_from_postgis(malicious_name, mock_engine, "public") - - mock_read_postgis.assert_not_called() - - @patch("data_manipulation.ingestion.gpd.read_postgis") - @patch("data_manipulation.ingestion.select") - @patch("data_manipulation.ingestion.Table") - @patch("data_manipulation.ingestion.MetaData") - def test_read_data_raises_exception_on_error( - self, - mock_metadata_class: Mock, - mock_table_class: Mock, - mock_select: Mock, - mock_read_postgis: Mock, - mock_engine: Mock, - ) -> None: - """Test that errors during reading are raised.""" - - # Mock the metadata and table - mock_metadata = MagicMock() - mock_metadata_class.return_value = mock_metadata - mock_table = MagicMock() - # Mock table.c to have the 'geom' column - mock_table.c = {"geom": MagicMock()} - mock_table_class.return_value = mock_table - - # Mock the select query - mock_query = MagicMock() - mock_select.return_value = mock_query - mock_compiled = MagicMock() - mock_compiled.__str__ = MagicMock(return_value="SELECT * FROM test_table") - mock_query.compile.return_value = mock_compiled - - -class TestApplyTransformations: - """Test cases for apply_transformations function.""" - - def test_apply_transformations_returns_unchanged_for_now(self) -> None: - """Test that apply_transformations currently returns data unchanged.""" - - gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - transformation_config = IntegrityTransformation() - - result = apply_transformations(gdf, transformation_config) - - # For now, should return the same data - assert result.equals(gdf) - - -class TestWriteDataToPostgis: - """Test cases for write_data_to_postgis function.""" - - @pytest.fixture - def mock_engine(self) -> Mock: - """Create a mock SQLAlchemy engine.""" - return Mock(spec=Engine) - - @patch("data_manipulation.ingestion._get_table_row_count") - def test_write_geodataframe_with_geom_column( - self, - mock_get_row_count: Mock, - mock_engine: Mock, - ) -> None: - """Test writing GeoDataFrame with 'geom' as active geometry.""" - - gdf = GeoDataFrame( - {"col1": [1, 2]}, - geometry=gpd.GeoSeries([Point(0, 0), Point(1, 1)], name="geom"), - ) - mock_get_row_count.return_value = 2 - - with patch.object(gdf, "to_postgis") as mock_to_postgis: - write_data_to_postgis(gdf, "test_table", mock_engine, "public") - - mock_to_postgis.assert_called_once() - args, kwargs = mock_to_postgis.call_args - assert args == ("test_table", mock_engine) - assert kwargs["if_exists"] == "replace" - assert kwargs["schema"] == "public" - assert kwargs["index"] is False - # The geometry column must be created as a generic GEOMETRY type so that - # chunked appends with mixed geometry types do not clash. - geom_dtype = kwargs["dtype"]["geom"] - assert geom_dtype.geometry_type == "GEOMETRY" - - @patch("data_manipulation.ingestion._get_table_row_count") - def test_write_geodataframe_pins_srid_from_crs( - self, - mock_get_row_count: Mock, - mock_engine: Mock, - ) -> None: - """The generic GEOMETRY column inherits the SRID of the GeoDataFrame CRS.""" - gdf = GeoDataFrame( - {"col1": [1, 2]}, - geometry=gpd.GeoSeries([Point(0, 0), Point(1, 1)], name="geom", crs="EPSG:4326"), - ) - mock_get_row_count.return_value = 2 - - with patch.object(gdf, "to_postgis") as mock_to_postgis: - write_data_to_postgis(gdf, "test_table", mock_engine, "public") - - geom_dtype = mock_to_postgis.call_args.kwargs["dtype"]["geom"] - assert geom_dtype.geometry_type == "GEOMETRY" - assert geom_dtype.srid == 4326 - - @patch("data_manipulation.ingestion._get_table_row_count") - def test_write_geodataframe_renames_geometry_column( - self, - mock_get_row_count: Mock, - mock_engine: Mock, - ) -> None: - """Test writing GeoDataFrame renames geometry column to 'geom'.""" - - gdf = GeoDataFrame( - {"col1": [1, 2]}, - geometry=gpd.GeoSeries([Point(0, 0), Point(1, 1)], name="geometry"), - ) - mock_get_row_count.return_value = 2 - - with patch.object(gdf, "to_postgis") as mock_to_postgis: - write_data_to_postgis(gdf, "test_table", mock_engine, "public") - - # Should have been renamed to 'geom' - assert gdf.geometry.name == "geom" - mock_to_postgis.assert_called_once() - - @patch("data_manipulation.ingestion._get_table_row_count") - def test_write_geodataframe_without_geometry( - self, - mock_get_row_count: Mock, - mock_engine: Mock, - ) -> None: - """Test writing GeoDataFrame without active geometry column.""" - - # Create a GeoDataFrame but set geometry to None - gdf = GeoDataFrame({"col1": [1, 2], "col2": [3, 4]}) - gdf._geometry_column_name = None - mock_get_row_count.return_value = 2 - - with patch.object(gdf, "to_postgis") as mock_to_postgis: - write_data_to_postgis(gdf, "test_table", mock_engine, "public") - - mock_to_postgis.assert_called_once() - - @patch("data_manipulation.ingestion._get_table_row_count") - def test_write_dataframe_without_geometry( - self, - mock_get_row_count: Mock, - mock_engine: Mock, - ) -> None: - """Test writing regular DataFrame (non-geographic data).""" - - df = DataFrame({"col1": [1, 2], "col2": [3, 4]}) - mock_get_row_count.return_value = 2 - - with patch.object(df, "to_sql") as mock_to_sql: - write_data_to_postgis(df, "test_table", mock_engine, "public") - - mock_to_sql.assert_called_once_with( - "test_table", mock_engine, if_exists="replace", schema="public", index=False - ) - - @patch("data_manipulation.ingestion._get_table_row_count") - def test_write_dataframe_removes_geom_column_if_present( - self, - mock_get_row_count: Mock, - mock_engine: Mock, - ) -> None: - """Test that DataFrame with 'geom' column has it removed.""" - - df = DataFrame({"col1": [1, 2], "geom": ["point1", "point2"]}) - mock_get_row_count.return_value = 2 - - with patch("pandas.DataFrame.to_sql", return_value=None) as mock_to_sql: - write_data_to_postgis(df, "test_table", mock_engine, "public") - - # Verify to_sql was called - mock_to_sql.assert_called_once() - - # Verify the instance has the columns we expect - assert "geom" not in df.columns, "geom column should have been removed" - assert "col1" in df.columns, "col1 column should still be present" - - def test_write_validates_table_name(self, mock_engine: Mock) -> None: - """Test that write_data validates table name.""" - - gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - - with pytest.raises(ValueError): - write_data_to_postgis(gdf, "invalid-table-name!", mock_engine, "public") - - def test_write_rejects_table_name_too_long_for_postgis_index(self, mock_engine: Mock) -> None: - """Names that would overflow PostGIS's `idx__geom` index must be rejected up-front.""" - gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - too_long = "a" * (POSTGIS_TABLE_NAME_MAX_LENGTH + 1) - - with patch.object(gdf, "to_postgis") as mock_to_postgis: - with pytest.raises(ValueError, match="exceeds maximum"): - write_data_to_postgis(gdf, too_long, mock_engine, "public") - mock_to_postgis.assert_not_called() - - def test_write_accepts_table_name_at_postgis_cap(self, mock_engine: Mock) -> None: - """A table name at the PostGIS-safe cap must pass validation.""" - gdf = GeoDataFrame( - {"col1": [1, 2]}, - geometry=gpd.GeoSeries([Point(0, 0), Point(1, 1)], name="geom"), - ) - at_cap = "a" * POSTGIS_TABLE_NAME_MAX_LENGTH - - with patch("data_manipulation.ingestion._get_table_row_count", return_value=2): - with patch.object(gdf, "to_postgis") as mock_to_postgis: - write_data_to_postgis(gdf, at_cap, mock_engine, "public") - mock_to_postgis.assert_called_once() - - def test_write_sql_injection_prevented(self, mock_engine: Mock) -> None: - """Test that SQL injection attempts are prevented.""" - - gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - malicious_names = [ - "table; DROP TABLE users--", - "table' OR '1'='1", - 'table" DROP SCHEMA public CASCADE--', - ] - - for malicious_name in malicious_names: - with pytest.raises(ValueError): - write_data_to_postgis(gdf, malicious_name, mock_engine, "public") - - @patch("data_manipulation.ingestion._get_table_row_count") - def test_write_logs_row_count( - self, - mock_get_row_count: Mock, - mock_engine: Mock, - ) -> None: - """Test that row count is logged after successful write.""" - - gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - mock_get_row_count.return_value = 2 - - with patch.object(gdf, "to_postgis"): - write_data_to_postgis(gdf, "test_table", mock_engine, "public") - - mock_get_row_count.assert_called_once_with("test_table", mock_engine, "public") - - @patch("data_manipulation.ingestion._get_table_row_count") - def test_write_raises_exception_on_error( - self, - mock_get_row_count: Mock, - mock_engine: Mock, - ) -> None: - """Test that errors during writing are raised.""" - - gdf = GeoDataFrame({"col1": [1, 2], "geometry": [Point(0, 0), Point(1, 1)]}) - - with patch.object(gdf, "to_postgis", side_effect=Exception("Write failed")): - with pytest.raises(Exception, match="Write failed"): - write_data_to_postgis(gdf, "test_table", mock_engine, "public") - - -class TestIngestDataFromDatabaseIntoPostgis: - """Test cases for ingest_data_from_database_into_postgis function.""" - - @pytest.fixture - def mock_source_engine(self) -> Mock: - return Mock(spec=Engine) - - @pytest.fixture - def mock_target_engine(self) -> Mock: - return Mock(spec=Engine) - - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion.pd.read_sql") - @patch("data_manipulation.ingestion.select") - @patch("data_manipulation.ingestion.Table") - @patch("data_manipulation.ingestion.MetaData") - def test_ingest_non_geographic_table( - self, - mock_metadata: Mock, - mock_table_cls: Mock, - mock_select: Mock, - mock_read_sql: Mock, - mock_write: Mock, - mock_source_engine: Mock, - mock_target_engine: Mock, - ) -> None: - """Non-geographic table is read with pd.read_sql and written to staging.""" - mock_table = MagicMock() - mock_table.c.__contains__ = Mock(return_value=False) # no geom column - mock_table_cls.return_value = mock_table - df = DataFrame({"id": [1, 2], "name": ["a", "b"]}) - mock_read_sql.return_value = df - + mock_run.return_value = _completed() ingest_data_from_database_into_postgis( source_schema="public", - source_table="communes", - source_engine=mock_source_engine, - target_table="staging_table", - target_engine=mock_target_engine, - target_schema="staging", - ) - - mock_read_sql.assert_called_once() - mock_write.assert_called_once_with( - df, "staging_table", mock_target_engine, "staging", if_exists="replace" - ) - - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion.gpd.read_postgis") - @patch("data_manipulation.ingestion.select") - @patch("data_manipulation.ingestion.Table") - @patch("data_manipulation.ingestion.MetaData") - @patch("data_manipulation.ingestion._get_geo_column_from_table", return_value="geom") - def test_ingest_geographic_table( - self, - mock_get_geo: Mock, - mock_metadata: Mock, - mock_table_cls: Mock, - mock_select: Mock, - mock_read_postgis: Mock, - mock_write: Mock, - mock_source_engine: Mock, - mock_target_engine: Mock, - ) -> None: - """Geographic table (has geom column) is read with gpd.read_postgis.""" - mock_table = MagicMock() - mock_table.c.__contains__ = Mock(return_value=True) # has geom column - mock_table_cls.return_value = mock_table - gdf = GeoDataFrame({"id": [1], "geom": [Point(0, 0)]}) - mock_read_postgis.return_value = gdf - - ingest_data_from_database_into_postgis( - source_schema="geo", - source_table="rivers", - source_engine=mock_source_engine, - target_table="staging_table", - target_engine=mock_target_engine, + source_table="src", + source_engine=source_engine, + target_table="dest", + target_engine=engine, target_schema="staging", ) + cmd = mock_run.call_args[0][0] + # both PG connection strings present + assert any(c.startswith("PG:") and "srchost" in c for c in cmd) + assert any(c.startswith("PG:") and "dbhost" in c for c in cmd) + assert "public.src" in cmd + assert "staging.dest" in cmd - mock_read_postgis.assert_called_once() - mock_write.assert_called_once_with( - gdf, "staging_table", mock_target_engine, "staging", if_exists="replace" - ) - - @patch("data_manipulation.ingestion.Table") - @patch("data_manipulation.ingestion.MetaData") - def test_source_table_not_found_raises( - self, - mock_metadata: Mock, - mock_table_cls: Mock, - mock_source_engine: Mock, - mock_target_engine: Mock, - ) -> None: - """Exception is raised when source table does not exist.""" - mock_table_cls.side_effect = Exception("Table not found") - - with pytest.raises(Exception, match="Table not found"): - ingest_data_from_database_into_postgis( - source_schema="public", - source_table="inexistant", - source_engine=mock_source_engine, - target_table="staging_table", - target_engine=mock_target_engine, - ) - - def test_invalid_source_schema_raises( - self, - mock_source_engine: Mock, - mock_target_engine: Mock, - ) -> None: - """ValueError is raised for invalid schema name.""" - with pytest.raises(ValueError): - ingest_data_from_database_into_postgis( - source_schema="Invalid-Schema", - source_table="my_table", - source_engine=mock_source_engine, - target_table="staging_table", - target_engine=mock_target_engine, - ) - - def test_invalid_source_table_raises( - self, - mock_source_engine: Mock, - mock_target_engine: Mock, - ) -> None: - """ValueError is raised for invalid table name.""" - with pytest.raises(ValueError): - ingest_data_from_database_into_postgis( - source_schema="public", - source_table="123bad", - source_engine=mock_source_engine, - target_table="staging_table", - target_engine=mock_target_engine, - ) - - -class TestIngestDataFromOgcServiceIntoPostgis: - """Test ingest_data_from_ogc_service_into_postgis.""" - - @pytest.fixture - def mock_engine(self) -> Mock: - return Mock(spec=Engine) - - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion.gpd.read_file") - def test_wfs_uses_wfs_gdal_prefix( - self, - mock_read_file: Mock, - mock_write: Mock, - mock_engine: Mock, - ) -> None: - """WFS protocol produces a WFS: prefixed GDAL source string.""" - mock_gdf = GeoDataFrame({"col1": [1]}, geometry=gpd.GeoSeries([Point(0, 0)])) - mock_read_file.return_value = mock_gdf +class TestIngestFromOgcService: + @patch("data_manipulation.ingestion.subprocess.run") + def test_wfs_prefix(self, mock_run: MagicMock, engine: Engine) -> None: + mock_run.return_value = _completed() ingest_data_from_ogc_service_into_postgis( - service_url="https://example.com/wfs", + service_url="https://example.org/wfs", layer_name="ns:buildings", protocol="wfs", - table_name="buildings_stg", - engine=mock_engine, - schema="public", - ) - - mock_read_file.assert_called_once_with( - "WFS:https://example.com/wfs", layer="ns:buildings", rows=slice(0, CHUNK_SIZE, None) - ) - mock_write.assert_called_once_with( - mock_gdf, "buildings_stg", mock_engine, "public", if_exists="replace" + table_name="places", + engine=engine, + schema="staging", ) + cmd = mock_run.call_args[0][0] + assert "WFS:https://example.org/wfs" in cmd + assert "ns:buildings" in cmd - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion.gpd.read_file") - def test_ogc_api_features_uses_oapif_gdal_prefix( - self, - mock_read_file: Mock, - mock_write: Mock, - mock_engine: Mock, - ) -> None: - """ogcFeatures protocol produces an OAPIF: prefixed GDAL source string.""" - mock_gdf = GeoDataFrame({"col1": [1]}, geometry=gpd.GeoSeries([Point(0, 0)])) - mock_read_file.return_value = mock_gdf - + @patch("data_manipulation.ingestion.subprocess.run") + def test_oapif_prefix_and_normalized_url(self, mock_run: MagicMock, engine: Engine) -> None: + mock_run.return_value = _completed() ingest_data_from_ogc_service_into_postgis( - service_url="https://example.com/ogcapi", - layer_name="parcels", + service_url="https://example.org/ogcapi/collections/buildings", + layer_name="buildings", protocol="ogcFeatures", - table_name="parcels_stg", - engine=mock_engine, - schema="public", + table_name="places", + engine=engine, ) + cmd = mock_run.call_args[0][0] + assert "OAPIF:https://example.org/ogcapi" in cmd - mock_read_file.assert_called_once_with( - "OAPIF:https://example.com/ogcapi", layer="parcels", rows=slice(0, CHUNK_SIZE, None) - ) - mock_write.assert_called_once_with( - mock_gdf, "parcels_stg", mock_engine, "public", if_exists="replace" - ) - - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion.gpd.read_file") - def test_unknown_protocol_falls_back_to_wfs_prefix( - self, - mock_read_file: Mock, - mock_write: Mock, - mock_engine: Mock, - ) -> None: - """Unknown protocol value falls back to the WFS: GDAL prefix.""" - mock_gdf = GeoDataFrame({"col1": [1]}, geometry=gpd.GeoSeries([Point(0, 0)])) - mock_read_file.return_value = mock_gdf - + @patch("data_manipulation.ingestion.subprocess.run") + def test_auth_passed_via_gdal_config(self, mock_run: MagicMock, engine: Engine) -> None: + mock_run.return_value = _completed() ingest_data_from_ogc_service_into_postgis( - service_url="https://example.com/wfs", - layer_name="ns:rivers", - protocol="unknown_protocol", - table_name="rivers_stg", - engine=mock_engine, - schema="public", - ) - - gdal_source = mock_read_file.call_args.args[0] - assert gdal_source.startswith("WFS:") - - @patch("data_manipulation.ingestion.write_data_to_postgis") - @patch("data_manipulation.ingestion.gpd.read_file") - def test_no_geometry_ingests_as_tabular( - self, - mock_read_file: Mock, - mock_write: Mock, - mock_engine: Mock, - ) -> None: - """Layers with all-null geometries are ingested as plain tabular DataFrames.""" - mock_gdf = GeoDataFrame({"col1": [1, 2]}, geometry=gpd.GeoSeries([None, None])) # type: ignore[arg-type] - mock_read_file.return_value = mock_gdf - + service_url="https://example.org/wfs", + layer_name="ns:buildings", + protocol="wfs", + table_name="places", + engine=engine, + auth=("alice", "s3cret"), + ) + cmd = mock_run.call_args[0][0] + assert "--config" in cmd + assert "GDAL_HTTP_USERPWD" in cmd + assert "alice:s3cret" in cmd + + @patch("data_manipulation.ingestion.subprocess.run") + def test_no_auth_means_no_userpwd(self, mock_run: MagicMock, engine: Engine) -> None: + mock_run.return_value = _completed() ingest_data_from_ogc_service_into_postgis( - service_url="https://example.com/ogcapi", - layer_name="observations", - protocol="ogcFeatures", - table_name="observations_stg", - engine=mock_engine, - schema="public", + service_url="https://example.org/wfs", + layer_name="ns:buildings", + protocol="wfs", + table_name="places", + engine=engine, ) + cmd = mock_run.call_args[0][0] + assert "GDAL_HTTP_USERPWD" not in cmd - written = mock_write.call_args.args[0] - assert isinstance(written, DataFrame) - assert "geometry" not in written.columns - @patch("data_manipulation.ingestion.gpd.read_file") - def test_read_exception_is_reraised( - self, - mock_read_file: Mock, - mock_engine: Mock, +class TestIngestFromFtp: + @patch("data_manipulation.ingestion.ingest_file_with_ogr2ogr") + @patch("data_manipulation.ingestion.urlretrieve") + def test_builds_credentialed_url( + self, mock_retrieve: MagicMock, mock_ingest: MagicMock, engine: Engine ) -> None: - """Exceptions from gpd.read_file are propagated to the caller.""" - mock_read_file.side_effect = RuntimeError("GDAL error") - - with pytest.raises(RuntimeError, match="GDAL error"): - ingest_data_from_ogc_service_into_postgis( - service_url="https://example.com/wfs", - layer_name="ns:buildings", - protocol="wfs", - table_name="buildings_stg", - engine=mock_engine, - schema="public", - ) - - -@pytest.mark.parametrize( - "service_url, expected_gdal_source", - [ - ("https://host/v1", "OAPIF:https://host/v1"), - ("https://host/v1/", "OAPIF:https://host/v1"), - ("https://host/v1/collections", "OAPIF:https://host/v1"), - ("https://host/v1/collections/", "OAPIF:https://host/v1"), - ("https://host/v1/collections/my_layer", "OAPIF:https://host/v1"), - ("https://host/v1/collections/my_layer/items", "OAPIF:https://host/v1"), - ], -) -@patch("data_manipulation.ingestion.write_data_to_postgis") -@patch("data_manipulation.ingestion.gpd.read_file") -def test_oapif_url_normalized_before_gdal( - mock_read_file: Mock, - mock_write: Mock, - service_url: str, - expected_gdal_source: str, -) -> None: - """GDAL always receives the service root URL regardless of what the user pasted.""" - mock_gdf = GeoDataFrame({"col": [1]}, geometry=gpd.GeoSeries([Point(0, 0)])) - mock_read_file.return_value = mock_gdf - - ingest_data_from_ogc_service_into_postgis( - service_url=service_url, - layer_name="my_layer", - protocol="ogcFeatures", - table_name="stg", - engine=Mock(spec=Engine), - ) - - mock_read_file.assert_called_once_with( - expected_gdal_source, layer="my_layer", rows=slice(0, CHUNK_SIZE, None) - ) + ingest_data_from_ftp_into_postgis( + "ftp://ftp.example.org/data/file.gpkg", + "places", + engine, + auth=("bob", "pw@ss"), + ) + # urlretrieve gets a credentialed URL (password URL-encoded) + called_url = mock_retrieve.call_args[0][0] + assert called_url.startswith("ftp://bob:") + assert "pw%40ss" in called_url + mock_ingest.assert_called_once() diff --git a/libs/data_manipulation/tests/test_transformation.py b/libs/data_manipulation/tests/test_transformation.py index 0398fef6..043d132d 100644 --- a/libs/data_manipulation/tests/test_transformation.py +++ b/libs/data_manipulation/tests/test_transformation.py @@ -1,141 +1,199 @@ -import geopandas as gpd -import pandas as pd -from shapely.geometry import Point - -from data_manipulation.models import ForceProjection, IntegrityTransformation -from data_manipulation.transformation.transform import apply_transformations -from data_manipulation.transformation.transform_encoding import apply_encoding -from data_manipulation.transformation.transform_projection import apply_projection - - -def test_apply_encoding_basic(): - """Test basic encoding transformation""" - df = pd.DataFrame({"text": ["Hello", "World"], "number": [1, 2]}) - - result = apply_encoding(df, "utf-8") - - assert isinstance(result, pd.DataFrame) - assert "text" in result.columns - assert len(result) == 2 - - -def test_apply_projection_basic(): - """Test basic projection transformation""" - gdf = gpd.GeoDataFrame( - {"name": ["A", "B"]}, geometry=[Point(0, 0), Point(1, 1)], crs="EPSG:4326" - ) - - result = apply_projection(gdf, "EPSG:3857") - - assert isinstance(result, gpd.GeoDataFrame) - assert result.crs.to_string() == "EPSG:3857" # type: ignore[misc] - assert len(result) == 2 - - -def test_apply_transformations_with_geometry(): - """Test apply_transformations with geometry creation""" - df = pd.DataFrame({"lon": [1.0, 2.0], "lat": [48.0, 49.0], "name": ["Paris", "Lyon"]}) - - config = IntegrityTransformation( - force_projection=ForceProjection(type="EPSG:4326", x_column="lon", y_column="lat") - ) - - result = apply_transformations(df, config) - - assert isinstance(result, gpd.GeoDataFrame) - assert "geometry" in result.columns - assert len(result) == 2 - - -def test_apply_transformations_with_encoding(): - """Test apply_transformations with encoding""" - df = pd.DataFrame({"text": ["café", "élève"], "number": [1, 2]}) - - config = IntegrityTransformation() - - result = apply_transformations(df, config) - - assert isinstance(result, pd.DataFrame) - assert len(result) == 2 - - -def test_apply_transformations_empty_config(): - """Test apply_transformations with empty config returns unchanged data""" - df = pd.DataFrame({"col1": [1, 2, 3], "col2": ["a", "b", "c"]}) - - result = apply_transformations(df, IntegrityTransformation()) - - assert isinstance(result, pd.DataFrame) - assert len(result) == 3 - assert list(result.columns) == ["col1", "col2"] - - -def test_apply_encoding_with_accents(): - """Test encoding transformation with accented characters""" - df = pd.DataFrame({"city": ["Paris", "Zürich", "Montréal"], "code": [1, 2, 3]}) - - result = apply_encoding(df, "utf-8") - - assert isinstance(result, pd.DataFrame) - assert "city" in result.columns - assert len(result) == 3 - - -def test_apply_encoding_mixed_types(): - """Test encoding with mixed column types (text and numeric)""" - df = pd.DataFrame({"text": ["Hello", "World"], "number": [1, 2], "float": [1.5, 2.5]}) - - result = apply_encoding(df, "utf-8") - - assert isinstance(result, pd.DataFrame) - assert result["number"].dtype in [int, "int64"] - assert result["float"].dtype in [float, "float64"] - - -def test_apply_projection_coordinate_change(): - """Test that projection actually changes coordinates""" - gdf = gpd.GeoDataFrame( - {"name": ["Point"]}, - geometry=[Point(2.3522, 48.8566)], # Paris coordinates - crs="EPSG:4326", - ) - - result = apply_projection(gdf, "EPSG:3857") - - # Coordinates should be different after reprojection - original_coords = (2.3522, 48.8566) - new_coords = (result.geometry.iloc[0].x, result.geometry.iloc[0].y) # type: ignore[misc] - - assert new_coords != original_coords - assert result.crs.to_string() == "EPSG:3857" # type: ignore[misc] - - -def test_apply_projection_multiple_points(): - """Test projection with multiple points""" - points = [Point(0, 0), Point(1, 1), Point(2, 2), Point(-1, -1)] - gdf = gpd.GeoDataFrame({"id": [1, 2, 3, 4]}, geometry=points, crs="EPSG:4326") - - result = apply_projection(gdf, "EPSG:3857") - - assert isinstance(result, gpd.GeoDataFrame) - assert len(result) == 4 - assert result.crs.to_string() == "EPSG:3857" # type: ignore[misc] - assert all(result.geometry.is_valid) - - -def test_apply_transformations_geometry_and_encoding(): - """Test combining geometry creation and encoding""" - df = pd.DataFrame( - {"lon": [2.3522, 4.8357], "lat": [48.8566, 45.7640], "city": ["Paris", "Lyon"]} - ) - - config = IntegrityTransformation( - force_projection=ForceProjection(type="EPSG:4326", x_column="lon", y_column="lat") +"""Tests for the SQL-native transformation builder. + +These tests construct ``Table`` objects in memory (no database needed) and +assert on the compiled PostgreSQL SQL produced by +:func:`build_transformation_select`. This is the single canonical builder used +by both the process path (``CREATE TABLE AS``) and the preview path, so +verifying its output guarantees preview/process parity (FR-021). +""" + +from sqlalchemy import Column, Integer, MetaData, Table, Text +from sqlalchemy.dialects import postgresql + +from data_manipulation.models import ( + CastType, + ColumnConfig, + ColumnFilter, + FilterOperator, + ForceProjection, + IntegrityTransformation, +) +from data_manipulation.transformation.sql_transform import ( + _parse_srid, # type: ignore[reportPrivateUsage] + build_transformation_select, +) + + +def _staging_table(*, with_geom: bool = True) -> Table: + metadata = MetaData(schema="staging") + cols = [ + Column("name", Text), + Column("population", Text), + Column("active", Text), + Column("created", Text), + Column("lon", Text), + Column("lat", Text), + Column("ratio", Integer), + ] + if with_geom: + cols.append(Column("geom", Text)) + return Table("places", metadata, *cols) + + +def _compile(table: Table, config: IntegrityTransformation | None) -> str: + tq = build_transformation_select(table, config) + return str( + tq.select.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": False}, + ) ) - result = apply_transformations(df, config) - assert isinstance(result, gpd.GeoDataFrame) - assert "geometry" in result.columns - assert "city" in result.columns - assert len(result) == 2 +class TestParseSrid: + def test_parses_epsg_prefixed(self) -> None: + assert _parse_srid("EPSG:2154") == 2154 + + def test_parses_bare_code(self) -> None: + assert _parse_srid("4326") == 4326 + + def test_empty_returns_none(self) -> None: + assert _parse_srid("") is None + assert _parse_srid(None) is None + + def test_unparseable_returns_none(self) -> None: + assert _parse_srid("not-a-crs") is None + + +class TestPassthrough: + def test_none_config_selects_all_columns(self) -> None: + table = _staging_table() + tq = build_transformation_select(table, None) + # geom is emitted separately and excluded from property columns + assert "geom" not in tq.property_columns + assert tq.geom_column == "geom" + assert "name" in tq.property_columns + assert "population" in tq.property_columns + + def test_no_geom_table_has_no_geom_column(self) -> None: + table = _staging_table(with_geom=False) + tq = build_transformation_select(table, None) + assert tq.geom_column is None + + +class TestColumnSelection: + def test_excluded_column_is_omitted(self) -> None: + table = _staging_table() + config = IntegrityTransformation( + columns=[ + ColumnConfig(original_name="name"), + ColumnConfig(original_name="population", excluded=True), + ] + ) + sql = _compile(table, config) + assert "name" in sql + assert "population" not in sql + + def test_rename_uses_new_name_label(self) -> None: + table = _staging_table() + config = IntegrityTransformation( + columns=[ColumnConfig(original_name="name", new_name="label")] + ) + tq = build_transformation_select(table, config) + assert "label" in tq.property_columns + assert "name" not in tq.property_columns + + +class TestCasts: + def test_boolean_cast_uses_helper(self) -> None: + table = _staging_table() + config = IntegrityTransformation( + columns=[ColumnConfig(original_name="active", cast_type=CastType.BOOLEAN)] + ) + sql = _compile(table, config) + assert "datafeeder_to_bool" in sql + + def test_numeric_cast_uses_helper(self) -> None: + table = _staging_table() + config = IntegrityTransformation( + columns=[ColumnConfig(original_name="population", cast_type=CastType.NUMERIC)] + ) + sql = _compile(table, config) + assert "datafeeder_to_numeric" in sql + + def test_date_cast_uses_helper(self) -> None: + table = _staging_table() + config = IntegrityTransformation( + columns=[ColumnConfig(original_name="created", cast_type=CastType.DATE)] + ) + sql = _compile(table, config) + assert "datafeeder_to_date" in sql + + def test_text_cast_uses_sql_cast(self) -> None: + table = _staging_table() + config = IntegrityTransformation( + columns=[ColumnConfig(original_name="ratio", cast_type=CastType.TEXT)] + ) + sql = _compile(table, config) + assert "CAST" in sql.upper() + + +class TestFilters: + def test_contains_filter_emits_where_ilike(self) -> None: + table = _staging_table() + config = IntegrityTransformation( + columns=[ + ColumnConfig( + original_name="name", + filter=ColumnFilter(operator=FilterOperator.CONTAINS, value="paris"), + ) + ] + ) + sql = _compile(table, config).upper() + assert "WHERE" in sql + assert "ILIKE" in sql + + def test_filter_value_is_bound_not_inlined(self) -> None: + table = _staging_table() + config = IntegrityTransformation( + columns=[ + ColumnConfig( + original_name="name", + filter=ColumnFilter(operator=FilterOperator.EXACTLY, value="paris"), + ) + ] + ) + sql = _compile(table, config) + # value must be a bound parameter, never inlined into the SQL text + assert "paris" not in sql + + +class TestProjection: + def test_force_projection_relabels_with_setsrid_not_transform(self) -> None: + """force_projection mirrors geopandas set_crs: relabel, not reproject.""" + table = _staging_table() + config = IntegrityTransformation( + columns=[ + ColumnConfig(original_name="name"), + ColumnConfig(original_name="geom"), + ], + force_projection=ForceProjection(type="EPSG:2154"), + ) + tq = build_transformation_select(table, config) + compiled = tq.select.compile(dialect=postgresql.dialect()) + sql = str(compiled) + assert "ST_SetSRID" in sql + assert 2154 in compiled.params.values() + assert "ST_Transform" not in sql + + def test_xy_columns_build_point(self) -> None: + table = _staging_table(with_geom=False) + config = IntegrityTransformation( + columns=[ColumnConfig(original_name="name")], + force_projection=ForceProjection(type="EPSG:4326", x_column="lon", y_column="lat"), + ) + tq = build_transformation_select(table, config) + sql = _compile(table, config) + assert tq.geom_column == "geom" + assert "ST_MakePoint" in sql + assert "ST_SetSRID" in sql diff --git a/uv.lock b/uv.lock index 9d049083..13f920f1 100644 --- a/uv.lock +++ b/uv.lock @@ -122,21 +122,6 @@ wheels = [ { 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" }, ] -[[package]] -name = "chardet" -version = "7.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, - { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, - { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, - { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, - { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, - { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, -] - [[package]] name = "charset-normalizer" version = "3.4.4" @@ -277,10 +262,9 @@ source = { editable = "libs/data_manipulation" } dependencies = [ { name = "chardet" }, { name = "geoalchemy2" }, - { name = "geopandas" }, { name = "geoservercloud" }, - { name = "pyarrow" }, { name = "pydantic" }, + { name = "pyproj" }, { name = "sqlalchemy" }, ] @@ -296,10 +280,9 @@ dev = [ requires-dist = [ { name = "chardet", specifier = "==7.4.3" }, { name = "geoalchemy2", specifier = "==0.19.0" }, - { name = "geopandas", specifier = "==1.1.3" }, { name = "geoservercloud", git = "https://github.com/camptocamp/python-geoservercloud.git" }, - { name = "pyarrow", specifier = "==24.0.0" }, { name = "pydantic", specifier = "==2.13.4" }, + { name = "pyproj", specifier = "==3.7.2" }, { name = "sqlalchemy", specifier = "==2.0.49" }, ] @@ -568,23 +551,6 @@ dependencies = [ { name = "types-requests" }, ] -[[package]] -name = "geopandas" -version = "1.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "pyogrio" }, - { name = "pyproj" }, - { name = "shapely" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/85/ba/8e6b2091878e99e86a36a814dcaeff652ed48bdb03d53e78e15aaa63a914/geopandas-1.1.3.tar.gz", hash = "sha256:91a31989b6f566012838d21d5f8033f37dce882079ccb7cfdc40d5ccce7f284f", size = 336718, upload-time = "2026-03-09T21:49:09.545Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/78/6a04792ace63a93e162f1305392d500ae8ddcb620e7eb88a22fd622b35bb/geopandas-1.1.3-py3-none-any.whl", hash = "sha256:90d62a64f95eaa3be2ccc115c5f3d6e24208bb11983b390fdc0621a3eccd0230", size = 342514, upload-time = "2026-03-09T21:49:07.973Z" }, -] - [[package]] name = "geoservercloud" version = "0.0.0" @@ -865,25 +831,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] -[[package]] -name = "numpy" -version = "2.3.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e", size = 16733873, upload-time = "2025-11-16T22:49:49.84Z" }, - { url = "https://files.pythonhosted.org/packages/c5/65/df0db6c097892c9380851ab9e44b52d4f7ba576b833996e0080181c0c439/numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769", size = 12259838, upload-time = "2025-11-16T22:49:52.863Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e1/1ee06e70eb2136797abe847d386e7c0e830b67ad1d43f364dd04fa50d338/numpy-2.3.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5", size = 5088378, upload-time = "2025-11-16T22:49:55.055Z" }, - { url = "https://files.pythonhosted.org/packages/6d/9c/1ca85fb86708724275103b81ec4cf1ac1d08f465368acfc8da7ab545bdae/numpy-2.3.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4", size = 6628559, upload-time = "2025-11-16T22:49:57.371Z" }, - { url = "https://files.pythonhosted.org/packages/74/78/fcd41e5a0ce4f3f7b003da85825acddae6d7ecb60cf25194741b036ca7d6/numpy-2.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d", size = 14250702, upload-time = "2025-11-16T22:49:59.632Z" }, - { url = "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28", size = 16606086, upload-time = "2025-11-16T22:50:02.127Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c5/5ad26fbfbe2012e190cc7d5003e4d874b88bb18861d0829edc140a713021/numpy-2.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b", size = 16025985, upload-time = "2025-11-16T22:50:04.536Z" }, - { url = "https://files.pythonhosted.org/packages/d2/fa/dd48e225c46c819288148d9d060b047fd2a6fb1eb37eae25112ee4cb4453/numpy-2.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c", size = 18542976, upload-time = "2025-11-16T22:50:07.557Z" }, - { url = "https://files.pythonhosted.org/packages/05/79/ccbd23a75862d95af03d28b5c6901a1b7da4803181513d52f3b86ed9446e/numpy-2.3.5-cp312-cp312-win32.whl", hash = "sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952", size = 6285274, upload-time = "2025-11-16T22:50:10.746Z" }, - { url = "https://files.pythonhosted.org/packages/2d/57/8aeaf160312f7f489dea47ab61e430b5cb051f59a98ae68b7133ce8fa06a/numpy-2.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa", size = 12782922, upload-time = "2025-11-16T22:50:12.811Z" }, - { url = "https://files.pythonhosted.org/packages/78/a6/aae5cc2ca78c45e64b9ef22f089141d661516856cf7c8a54ba434576900d/numpy-2.3.5-cp312-cp312-win_arm64.whl", hash = "sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013", size = 10194667, upload-time = "2025-11-16T22:50:16.16Z" }, -] - [[package]] name = "owslib" version = "0.35.0" @@ -908,27 +855,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "pandas" -version = "2.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, -] -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/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" }, -] - [[package]] name = "pastel" version = "0.2.1" @@ -1015,21 +941,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, ] -[[package]] -name = "pyarrow" -version = "24.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, - { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, - { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, -] - [[package]] name = "pycparser" version = "2.23" @@ -1139,25 +1050,6 @@ crypto = [ { name = "cryptography" }, ] -[[package]] -name = "pyogrio" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "numpy" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/d4/12f86b1ed09721363da4c09622464b604c851a9223fc0c6b393fb2012208/pyogrio-0.12.1.tar.gz", hash = "sha256:e548ab705bb3e5383693717de1e6c76da97f3762ab92522cb310f93128a75ff1", size = 303289, upload-time = "2025-11-28T19:04:53.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/e0/656b6536549d41b5aec57e0deca1f269b4f17532f0636836f587e581603a/pyogrio-0.12.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:7a0d5ca39184030aec4cde30f4258f75b227a854530d2659babc8189d76e657d", size = 23661857, upload-time = "2025-11-28T19:03:27.744Z" }, - { url = "https://files.pythonhosted.org/packages/14/78/313259e40da728bdb60106ffdc7ea8224d164498cb838ecb79b634aab967/pyogrio-0.12.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:feaff42bbe8087ca0b30e33b09d1ce049ca55fe83ad83db1139ef37d1d04f30c", size = 25237106, upload-time = "2025-11-28T19:03:30.018Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ca/5368571a8b00b941ccfbe6ea29a5566aaffd45d4eb1553b956f7755af43e/pyogrio-0.12.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:81096a5139532de5a8003ef02b41d5d2444cb382a9aecd1165b447eb549180d3", size = 31417048, upload-time = "2025-11-28T19:03:32.572Z" }, - { url = "https://files.pythonhosted.org/packages/ef/85/6eeb875f27bf498d657eb5dab9f58e4c48b36c9037122787abee9a1ba4ba/pyogrio-0.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:41b78863f782f7a113ed0d36a5dc74d59735bd3a82af53510899bb02a18b06bb", size = 30952115, upload-time = "2025-11-28T19:03:35.332Z" }, - { url = "https://files.pythonhosted.org/packages/36/f7/cf8bec9024625947e1a71441906f60a5fa6f9e4c441c4428037e73b1fcc8/pyogrio-0.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8b65be8c4258b27cc8f919b21929cecdadda4c353e3637fa30850339ef4d15c5", size = 32537246, upload-time = "2025-11-28T19:03:37.969Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/7c9f5e428273574e69f217eba3a6c0c42936188ad4dcd9e2c41ebb711188/pyogrio-0.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:1291b866c2c81d991bda15021b08b3621709b40ee3a85689229929e9465788bf", size = 22933980, upload-time = "2025-11-28T19:03:41.047Z" }, -] - [[package]] name = "pyproj" version = "3.7.2" @@ -1264,15 +1156,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, ] -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, -] - [[package]] name = "pywin32" version = "311" @@ -1442,25 +1325,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/ac/d6286ea0d49e7b58847faf67b00e56bb4ba3d525281e2ac306e1f1f353da/sentry_sdk-2.47.0-py2.py3-none-any.whl", hash = "sha256:d72f8c61025b7d1d9e52510d03a6247b280094a327dd900d987717a4fce93412", size = 411088, upload-time = "2025-12-03T14:06:35.374Z" }, ] -[[package]] -name = "shapely" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, - { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, - { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, - { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, -] - [[package]] name = "shellingham" version = "1.5.4" From 5378a438b248628ead627fb34ee38f70a9d9d647 Mon Sep 17 00:00:00 2001 From: fnecas Date: Fri, 14 Aug 2026 16:11:04 +0200 Subject: [PATCH 03/24] feat; bump python to 3.13, airflow to 3.2.2 and add a base image with debian trixie and gdal --- .python-version | 2 +- Makefile | 21 + apps/backend/.python-version | 2 +- apps/backend/Dockerfile | 6 +- apps/backend/pyproject.toml | 4 +- apps/elt/.python-version | 2 +- apps/elt/pyproject.toml | 7 +- apps/elt/uv.lock | 857 +++--- docker/Dockerfile.airflow | 63 +- docker/airflow-base/Dockerfile | 2293 +++++++++++++++++ docker/airflow-base/README.md | 41 + .../scripts/docker/keys/mariadb.asc | 104 + .../scripts/docker/keys/microsoft.asc | 42 + .../scripts/docker/keys/postgres.asc | 77 + .../scripts/docker/keys/python-3.10.asc | 0 docker/compose.airflow.yaml | 11 +- libs/data_manipulation/.python-version | 2 +- libs/data_manipulation/pyproject.toml | 2 +- .../src/data_manipulation/constants.py | 6 + .../src/data_manipulation/ingestion.py | 23 +- .../data_manipulation/tests/test_ingestion.py | 11 + pyproject.toml | 2 +- pyrightconfig.json | 2 +- uv.lock | 556 ++-- 24 files changed, 3431 insertions(+), 705 deletions(-) create mode 100644 docker/airflow-base/Dockerfile create mode 100644 docker/airflow-base/README.md create mode 100644 docker/airflow-base/scripts/docker/keys/mariadb.asc create mode 100644 docker/airflow-base/scripts/docker/keys/microsoft.asc create mode 100644 docker/airflow-base/scripts/docker/keys/postgres.asc create mode 100644 docker/airflow-base/scripts/docker/keys/python-3.10.asc diff --git a/.python-version b/.python-version index e4fba218..24ee5b1b 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.12 +3.13 diff --git a/Makefile b/Makefile index d385129a..376dcad5 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,14 @@ # Display help message by default default: help +# Apache Airflow base image (built locally on Debian Trixie from the official +# Airflow Dockerfile, since apache/airflow only ships bookworm based images). +AIRFLOW_VERSION ?= 3.2.2 +AIRFLOW_PYTHON_VERSION ?= 3.13.5 +AIRFLOW_BASE_IMAGE ?= datafeeder-airflow-base:$(AIRFLOW_VERSION)-trixie +export AIRFLOW_VERSION +export AIRFLOW_BASE_IMAGE + help: ## Display this help message @echo "Usage: make " @echo @@ -34,6 +42,19 @@ up: build-libs ## Start all services including Airflow, GeoServer and GeoNetwork up-no-airflow: build-libs ## Start all services including GeoServer and GeoNetwork using Docker Compose (no Airflow, replaced with the local executor) docker compose up -d --wait --build +build-airflow-base: ## Build the Debian Trixie based Apache Airflow base image (from the official Dockerfile) + @if [ -z "$$(docker images -q $(AIRFLOW_BASE_IMAGE))" ]; then \ + echo "Building $(AIRFLOW_BASE_IMAGE) (Airflow $(AIRFLOW_VERSION), Python $(AIRFLOW_PYTHON_VERSION) on debian:trixie-slim)..."; \ + docker build \ + --build-arg BASE_IMAGE=debian:trixie-slim \ + --build-arg AIRFLOW_VERSION=$(AIRFLOW_VERSION) \ + --build-arg AIRFLOW_PYTHON_VERSION=$(AIRFLOW_PYTHON_VERSION) \ + -t $(AIRFLOW_BASE_IMAGE) \ + docker/airflow-base; \ + else \ + echo "$(AIRFLOW_BASE_IMAGE) already present, skipping (run 'docker rmi $(AIRFLOW_BASE_IMAGE)' to rebuild)."; \ + fi + down: ## Stop all services using Docker Compose docker compose --profile airflow down diff --git a/apps/backend/.python-version b/apps/backend/.python-version index e4fba218..24ee5b1b 100644 --- a/apps/backend/.python-version +++ b/apps/backend/.python-version @@ -1 +1 @@ -3.12 +3.13 diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 9094eb27..0014a906 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -8,7 +8,7 @@ FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv # ============================================================================ # Base stage: Common setup for all targets # ============================================================================ -FROM python:3.12-slim AS base +FROM python:3.13-slim AS base # Install required dependencies with APT RUN apt-get update && \ @@ -23,7 +23,7 @@ COPY --from=uv /uv /uvx /bin/ # - Silence uv complaining about not being able to use hard links, # - tell uv to byte-compile packages for faster application startups, # - prevent uv from accidentally downloading isolated Python builds, -# - pick a Python (use `/usr/bin/python3.12` on uv 0.5.0 and later), +# - pick a Python (use `/usr/bin/python3.13` on uv 0.5.0 and later), # - declare `/app` as the target for `uv sync`. # - declare a cache directory for uv to use # - disable uv caching to ensure fresh installs in CI/CD environments @@ -31,7 +31,7 @@ COPY --from=uv /uv /uvx /bin/ ENV UV_LINK_MODE=copy \ UV_COMPILE_BYTECODE=1 \ UV_PYTHON_DOWNLOADS=never \ - UV_PYTHON=python3.12 \ + UV_PYTHON=python3.13 \ UV_CACHE_DIR=/tmp/uv-cache \ UV_NO_CACHE=1 \ UV_FROZEN=1 diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index b3d0521e..8a9c7d9d 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -15,7 +15,7 @@ name = "datafeeder-backend" version = "0.1.0" description = "REST API for data ingestion in Datafeeder" readme = "README.md" -requires-python = "==3.12.*" +requires-python = "==3.13.*" dependencies = [ "fastapi[standard]==0.136.1", "uvicorn[standard]==0.46.0", @@ -28,7 +28,7 @@ dependencies = [ "psycopg[binary]==3.3.4", "geoservercloud", "geonetwork", - "apache-airflow-client==3.1.8", + "apache-airflow-client==3.2.2", "geojson-pydantic==2.1.1", "requests==2.33.1", "alembic==1.18.4", diff --git a/apps/elt/.python-version b/apps/elt/.python-version index e4fba218..24ee5b1b 100644 --- a/apps/elt/.python-version +++ b/apps/elt/.python-version @@ -1 +1 @@ -3.12 +3.13 diff --git a/apps/elt/pyproject.toml b/apps/elt/pyproject.toml index 55776c2f..56267c2a 100644 --- a/apps/elt/pyproject.toml +++ b/apps/elt/pyproject.toml @@ -10,12 +10,11 @@ name = "datafeeder-airflow-elt" version = "0.1.0" description = "Airflow DAGs for ELT in Datafeeder" readme = "README.md" -requires-python = "==3.12.*" +requires-python = "==3.13.*" dependencies = [ "data_manipulation", - "apache-airflow==3.1.8", # keep in sync with AIRFLOW_VERSION in docker/Dockerfile.airflow - "apache-airflow-providers-postgres==6.6.3", - "apache-airflow-providers-fab==3.6.4", # https://github.com/apache/airflow/pull/62919 explicit dependency to be removed when upgrading to airflow 3.2.x + "apache-airflow==3.2.2", # keep in sync with AIRFLOW_VERSION in docker/Dockerfile.airflow + "apache-airflow-providers-postgres==6.7.0", ] [dependency-groups] diff --git a/apps/elt/uv.lock b/apps/elt/uv.lock index ffda84d8..f3195ad5 100644 --- a/apps/elt/uv.lock +++ b/apps/elt/uv.lock @@ -1,11 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.12.*" -resolution-markers = [ - "sys_platform == 'win32'", - "sys_platform == 'emscripten'", - "sys_platform != 'emscripten' and sys_platform != 'win32'", -] +requires-python = "==3.13.*" [manifest] members = [ @@ -81,7 +76,6 @@ version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions" }, ] 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 = [ @@ -90,20 +84,20 @@ wheels = [ [[package]] name = "apache-airflow" -version = "3.1.8" +version = "3.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "apache-airflow-core" }, { name = "apache-airflow-task-sdk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/f7/d4197de17a52c1bb5fe59ae00cfa40e4cc9070c2dbf58c51cdc5d88d21a6/apache_airflow-3.1.8.tar.gz", hash = "sha256:b64f56aff0c30ebd271412f521376847c311df042a93fefb34f16e8fe9a9b20c", size = 28782, upload-time = "2026-03-11T19:28:09.165Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/ff/b4b06d87bf5ca41492a264ee43cdcbf1caaa55d9bacbaf1d4ef62ed49ef7/apache_airflow-3.2.2.tar.gz", hash = "sha256:5625a596be4a3b96d93d401412bd41a28181ff32ef1219af277ab1d42453b8d6", size = 30640, upload-time = "2026-05-29T05:20:10.088Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/31/c5fe03351b9a7d0d2cb7cb4b7f4cddb88ebc9efe47140d03d10adb8ea1f6/apache_airflow-3.1.8-py3-none-any.whl", hash = "sha256:efa5aa4c088544d49b7ef02b45651ec7d906f6e7537b55bebc311efac6f9a477", size = 12607, upload-time = "2026-03-11T19:27:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d9/34567a04e07e6a8acde4f3a96410c9aab29b88a58f5405d8165b8d1124f4/apache_airflow-3.2.2-py3-none-any.whl", hash = "sha256:5fe8f4ca7c930cf2ec5b4f0485074fff8c01f906b6a945ac5add3a760a9aac17", size = 12970, upload-time = "2026-05-29T05:19:55.297Z" }, ] [[package]] name = "apache-airflow-core" -version = "3.1.8" +version = "3.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "a2wsgi" }, @@ -118,6 +112,7 @@ dependencies = [ { name = "argcomplete" }, { name = "asgiref" }, { name = "attrs" }, + { name = "cachetools" }, { name = "cadwyn" }, { name = "colorlog" }, { name = "cron-descriptor" }, @@ -153,13 +148,12 @@ dependencies = [ { name = "python-daemon" }, { name = "python-dateutil" }, { name = "python-slugify" }, + { name = "pyyaml" }, { name = "requests" }, { name = "rich" }, { name = "rich-argparse" }, { name = "setproctitle" }, { name = "sqlalchemy", extra = ["asyncio"] }, - { name = "sqlalchemy-jsonfield" }, - { name = "sqlalchemy-utils" }, { name = "starlette" }, { name = "structlog" }, { name = "svcs" }, @@ -171,9 +165,9 @@ dependencies = [ { name = "uuid6" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/af/da5fb7c65674bc489f883c5aca2cfecb1179db86e68739f2273f15bac5d5/apache_airflow_core-3.1.8.tar.gz", hash = "sha256:484aa133b3a8684d64e0bac4f0354938a430bd9e4a7bcc2f142b3e59fc8c2a1c", size = 4191284, upload-time = "2026-03-11T19:28:12.338Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/5b/c168601bac11a91b3baa3c3b7d66e806fb9bd31e72699c6d09fee8a14582/apache_airflow_core-3.2.2.tar.gz", hash = "sha256:5473699fca8b9b64680d24c6fd2adbf1ad1168facc849cdfaec0f9715d76d7a1", size = 6451753, upload-time = "2026-05-29T05:20:38.777Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/b6/98bc3878830bf85506ebf9ef6fef842ee3ce1b72e74cf9acf9d805712d2c/apache_airflow_core-3.1.8-py3-none-any.whl", hash = "sha256:ba8593b64eb11608ae16b2378700afdacc2c1be9dfd03719860aa3a118556ac6", size = 3910176, upload-time = "2026-03-11T19:28:07.083Z" }, + { url = "https://files.pythonhosted.org/packages/ca/63/8cb6a31954f40517fdd905113e0fef0fad46275c1b4c128b7bc916933768/apache_airflow_core-3.2.2-py3-none-any.whl", hash = "sha256:877be429d59193b5e9aab1ae69f44066134bf483c633e9f7c59a9220ebfcc013", size = 6102519, upload-time = "2026-05-29T05:20:08.257Z" }, ] [[package]] @@ -248,7 +242,7 @@ wheels = [ [[package]] name = "apache-airflow-providers-postgres" -version = "6.6.3" +version = "6.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "apache-airflow" }, @@ -257,9 +251,9 @@ dependencies = [ { name = "asyncpg" }, { name = "psycopg2-binary" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/fa/ff9c18754b482e612a548c114c43804c4275c0175fd7a12ad8ae7599deb3/apache_airflow_providers_postgres-6.6.3.tar.gz", hash = "sha256:f9013dfd5d57723d3245779ad8d9d1b586bcf928024611ff9c22db3861121bcf", size = 62537, upload-time = "2026-04-12T14:22:33.321Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/80/ca228db8942ba4d8e5d158d72fa9061e8f1a726c59eccbdba8a8cbaf4de4/apache_airflow_providers_postgres-6.7.0.tar.gz", hash = "sha256:abaaa3b71abfac9b2caf5aa2afff496e0fd498bce327d4e744fc08f779c44c40", size = 63333, upload-time = "2026-05-23T12:32:50.163Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/7b/6b610a69c2bba38dfac1679b7b27b8f368b69230764d98bf8ddba4109eda/apache_airflow_providers_postgres-6.6.3-py3-none-any.whl", hash = "sha256:b61aaf332907fff7aad11c9d3841279a8e828c3b04397cb480621178bc3c4f12", size = 24027, upload-time = "2026-04-12T14:20:56.792Z" }, + { url = "https://files.pythonhosted.org/packages/60/34/d6a34a11c8b9b4743b880a1d0fd0761f15cdb2af161d19a0b4808952336c/apache_airflow_providers_postgres-6.7.0-py3-none-any.whl", hash = "sha256:aec6e35d0194e65e3095cebc2d23fdba636dcb0781e27edcaf6aa3826e102ed9", size = 24390, upload-time = "2026-05-23T12:31:53.152Z" }, ] [[package]] @@ -291,7 +285,7 @@ wheels = [ [[package]] name = "apache-airflow-task-sdk" -version = "1.1.8" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "apache-airflow-core" }, @@ -303,19 +297,24 @@ dependencies = [ { name = "greenback" }, { name = "httpx" }, { name = "jinja2" }, + { name = "jsonschema" }, { name = "methodtools" }, { name = "msgspec" }, + { name = "packaging" }, + { name = "pathspec" }, { name = "pendulum" }, + { name = "pluggy" }, { name = "psutil" }, { name = "pydantic" }, { name = "pygtrie" }, { name = "python-dateutil" }, { name = "structlog" }, { name = "tenacity" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/bf/d437471dce6ffa15c8847d277cdb114e41e7076e0eb22e806d5d335f37c0/apache_airflow_task_sdk-1.1.8.tar.gz", hash = "sha256:d9b6027906a0e179c7ddc550c13717663785460a539b933741f22c47d10f4930", size = 1291245, upload-time = "2026-03-11T19:28:21.262Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/05/7dbae857bd82cc4a768bd13e8dccd90515cce4eac8ab0bfb3023008fdc6a/apache_airflow_task_sdk-1.2.2.tar.gz", hash = "sha256:3c8b3c4c86fe97dad5779004218c351cfd85f3dfc5a3f68b86127f266f9b286a", size = 1519184, upload-time = "2026-05-29T05:20:53.526Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/a9/9055473ca151dd630d0da2959f82ce2da13baf3a8cde6d7d5e3fdc5cd69e/apache_airflow_task_sdk-1.1.8-py3-none-any.whl", hash = "sha256:bd4d971b7450a297b4c9f59410b1d6bce482c349cd187b393a5b8842a2af57e3", size = 305695, upload-time = "2026-03-11T19:28:19.132Z" }, + { url = "https://files.pythonhosted.org/packages/43/ca/478018625c726131f9ea774eb839642ebeba09588792a56d5f7cd7f4e301/apache_airflow_task_sdk-1.2.2-py3-none-any.whl", hash = "sha256:709552227b8139b1264413fdc4f0ef3034dd0519c8adeaffd0c9c908a608e6c5", size = 492829, upload-time = "2026-05-29T05:20:51.182Z" }, ] [[package]] @@ -359,14 +358,14 @@ version = "0.31.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, - { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, - { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, - { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, - { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, ] [[package]] @@ -416,7 +415,7 @@ wheels = [ [[package]] name = "cadwyn" -version = "5.4.6" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi" }, @@ -426,9 +425,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/db/93a7a9fc8b0d272fcf6ddd9a9847743fc2ea98a17e2ff07942d56fd03e53/cadwyn-5.4.6.tar.gz", hash = "sha256:2e6165aee2eb4b7a465d2998c4fc406911036bf9dee1d1562eeba39ae647f79f", size = 646040, upload-time = "2025-12-18T19:11:52.635Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/31/9a986a9fe20c6b52bde0dd23c9fc002388ab0c8f7b30a37217b07aa1875f/cadwyn-7.0.0.tar.gz", hash = "sha256:3b57549a37e218dffb55ac5d188639de0516207f05642b084f23627c5a44d614", size = 662353, upload-time = "2026-06-06T16:34:39.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/5b/ad7598f6b428dc3595878979cfe1e9df8d65663300ec83d2a69f46d81abf/cadwyn-5.4.6-py3-none-any.whl", hash = "sha256:6c0611bd58de6b92bd7bb2a2779b5323bb20b29cfacbf0e4f71a0cccaa5b47b8", size = 59676, upload-time = "2025-12-18T19:11:50.803Z" }, + { url = "https://files.pythonhosted.org/packages/73/c4/efee5781dc8b3a50fd876d413cad6db6ee69e1f21d5a07ca469cec03cd22/cadwyn-7.0.0-py3-none-any.whl", hash = "sha256:727d3c444ae992bb2a238246d13f173e4802c92e1c901458975549e6f0522560", size = 61194, upload-time = "2026-06-06T16:34:37.768Z" }, ] [[package]] @@ -449,18 +448,18 @@ dependencies = [ ] 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/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" }, ] [[package]] @@ -484,22 +483,22 @@ 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, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { 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/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" }, ] @@ -606,10 +605,9 @@ source = { editable = "../../libs/data_manipulation" } dependencies = [ { name = "chardet" }, { name = "geoalchemy2" }, - { name = "geopandas" }, { name = "geoservercloud" }, - { name = "pyarrow" }, { name = "pydantic" }, + { name = "pyproj" }, { name = "sqlalchemy" }, ] @@ -625,10 +623,9 @@ dev = [ requires-dist = [ { name = "chardet", specifier = "==7.4.3" }, { name = "geoalchemy2", specifier = "==0.19.0" }, - { name = "geopandas", specifier = "==1.1.3" }, { name = "geoservercloud", git = "https://github.com/camptocamp/python-geoservercloud.git" }, - { name = "pyarrow", specifier = "==24.0.0" }, { name = "pydantic", specifier = "==2.13.4" }, + { name = "pyproj", specifier = "==3.7.2" }, { name = "sqlalchemy", specifier = "==2.0.49" }, ] @@ -659,9 +656,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "apache-airflow", specifier = "==3.1.8" }, + { name = "apache-airflow", specifier = "==3.2.2" }, { name = "apache-airflow-providers-fab", specifier = "==3.6.4" }, - { name = "apache-airflow-providers-postgres", specifier = "==6.6.3" }, + { name = "apache-airflow-providers-postgres", specifier = "==6.7.0" }, { name = "data-manipulation", editable = "../../libs/data_manipulation" }, ] @@ -716,16 +713,18 @@ wheels = [ [[package]] name = "fastapi" -version = "0.117.1" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/7e/d9788300deaf416178f61fb3c2ceb16b7d0dc9f82a08fdb87a5e64ee3cc7/fastapi-0.117.1.tar.gz", hash = "sha256:fb2d42082d22b185f904ca0ecad2e195b851030bd6c5e4c032d1c981240c631a", size = 307155, upload-time = "2025-09-20T20:16:56.663Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/45/d9d3e8eeefbe93be1c50060a9d9a9f366dba66f288bb518a9566a23a8631/fastapi-0.117.1-py3-none-any.whl", hash = "sha256:33c51a0d21cab2b9722d4e56dbb9316f3687155be6b276191790d8da03507552", size = 95959, upload-time = "2025-09-20T20:16:53.661Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [package.optional-dependencies] @@ -734,6 +733,8 @@ standard-no-fastapi-cloud-cli = [ { name = "fastapi-cli", extra = ["standard-no-fastapi-cloud-cli"] }, { name = "httpx" }, { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, { name = "python-multipart" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -926,23 +927,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/78/0d5918bea58179dc7a6b724c097b3d972f875328460503f74521c46283bc/geoalchemy2-0.19.0-py3-none-any.whl", hash = "sha256:878a5859e23ae632fe20ea35412cda40f50c94fa69c9242587574941e4d4ec32", size = 81145, upload-time = "2026-04-12T21:02:15.337Z" }, ] -[[package]] -name = "geopandas" -version = "1.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "pyogrio" }, - { name = "pyproj" }, - { name = "shapely" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/85/ba/8e6b2091878e99e86a36a814dcaeff652ed48bdb03d53e78e15aaa63a914/geopandas-1.1.3.tar.gz", hash = "sha256:91a31989b6f566012838d21d5f8033f37dce882079ccb7cfdc40d5ccce7f284f", size = 336718, upload-time = "2026-03-09T21:49:09.545Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/78/6a04792ace63a93e162f1305392d500ae8ddcb620e7eb88a22fd622b35bb/geopandas-1.1.3-py3-none-any.whl", hash = "sha256:90d62a64f95eaa3be2ccc115c5f3d6e24208bb11983b390fdc0621a3eccd0230", size = 342514, upload-time = "2026-03-09T21:49:07.973Z" }, -] - [[package]] name = "geoservercloud" version = "0.0.0" @@ -988,16 +972,16 @@ version = "3.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, - { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, - { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, - { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, - { url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, + { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" }, ] [[package]] @@ -1009,16 +993,16 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, - { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, - { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, - { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, - { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, - { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, - { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, ] [[package]] @@ -1049,13 +1033,13 @@ version = "0.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, ] [[package]] @@ -1166,12 +1150,18 @@ version = "1.12.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" }, - { url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" }, + { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" }, + { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" }, + { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" }, + { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" }, ] [[package]] @@ -1179,18 +1169,26 @@ name = "libcst" version = "1.8.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyyaml" }, + { name = "pyyaml-ft" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" }, - { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" }, - { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" }, - { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" }, - { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" }, - { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" }, + { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" }, + { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" }, + { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" }, + { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, ] [[package]] @@ -1234,24 +1232,24 @@ version = "6.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, - { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, - { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, - { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, - { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, - { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, - { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, - { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, - { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, - { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, - { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, - { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, - { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, ] [[package]] @@ -1284,17 +1282,28 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] [[package]] @@ -1355,15 +1364,15 @@ version = "1.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, ] [[package]] @@ -1372,14 +1381,14 @@ version = "0.21.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, - { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, - { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, - { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, - { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, ] [[package]] @@ -1400,25 +1409,6 @@ wheels = [ { 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]] -name = "numpy" -version = "2.4.4" -source = { registry = "https://pypi.org/simple" } -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/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" }, -] - [[package]] name = "opentelemetry-api" version = "1.41.1" @@ -1578,33 +1568,21 @@ wheels = [ ] [[package]] -name = "pandas" -version = "3.0.2" +name = "pastel" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, -] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } wheels = [ - { 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/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, ] [[package]] -name = "pastel" -version = "0.2.1" +name = "pathlib-abc" +version = "0.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/cb/448649d7f25d228bf0be3a04590ab7afa77f15e056f8fa976ed05ec9a78f/pathlib_abc-0.5.2.tar.gz", hash = "sha256:fcd56f147234645e2c59c7ae22808b34c364bb231f685ddd9f96885aed78a94c", size = 33342, upload-time = "2025-10-10T18:37:20.524Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/c028a0731e202035f0e2e0bfbf1a3e46ad6c628cbb17f6f1cc9eea5d9ff1/pathlib_abc-0.5.2-py3-none-any.whl", hash = "sha256:4c9d94cf1b23af417ce7c0417b43333b06a106c01000b286c99de230d95eefbb", size = 19070, upload-time = "2025-10-10T18:37:19.437Z" }, ] [[package]] @@ -1626,16 +1604,16 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/56/dd0ea9f97d25a0763cda09e2217563b45714786118d8c68b0b745395d6eb/pendulum-3.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bf0b489def51202a39a2a665dcc4162d5e46934a740fe4c4fe3068979610156c", size = 337830, upload-time = "2026-01-30T11:21:08.298Z" }, - { url = "https://files.pythonhosted.org/packages/cf/98/83d62899bf7226fc12396de4bc1fb2b5da27e451c7c60790043aaf8b4731/pendulum-3.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:937a529aa302efa18dcf25e53834964a87ffb2df8f80e3669ab7757a6126beaf", size = 327574, upload-time = "2026-01-30T11:21:09.715Z" }, - { url = "https://files.pythonhosted.org/packages/76/fa/ff2aa992b23f0543c709b1a3f3f9ed760ec71fd02c8bb01f93bf008b52e4/pendulum-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85c7689defc65c4dc29bf257f7cca55d210fabb455de9476e1748d2ab2ae80d7", size = 339891, upload-time = "2026-01-30T11:21:11.089Z" }, - { url = "https://files.pythonhosted.org/packages/c5/4e/25b4fa11d19503d50d7b52d7ef943c0f20fd54422aaeb9e38f588c815c50/pendulum-3.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e216e5a412563ea2ecf5de467dcf3d02717947fcdabe6811d5ee360726b02b", size = 373726, upload-time = "2026-01-30T11:21:12.493Z" }, - { url = "https://files.pythonhosted.org/packages/4f/30/0acad6396c4e74e5c689aa4f0b0c49e2ecdcfce368e7b5bf35ca1c0fc61a/pendulum-3.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a2af22eeec438fbaac72bb7fba783e0950a514fba980d9a32db394b51afccec", size = 379827, upload-time = "2026-01-30T11:21:14.08Z" }, - { url = "https://files.pythonhosted.org/packages/3a/f7/e6a2fdf2a23d59b4b48b8fa89e8d4bf2dd371aea2c6ba8fcecec20a4acb9/pendulum-3.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3159cceb54f5aa8b85b141c7f0ce3fac8bdd1ffdc7c79e67dca9133eac7c4d11", size = 348921, upload-time = "2026-01-30T11:21:15.816Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f2/c15fa7f9ad4e181aa469b6040b574988bd108ccdf4ae509ad224f9e4db44/pendulum-3.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c39ea5e9ffa20ea8bae986d00e0908bd537c8468b71d6b6503ab0b4c3d76e0ea", size = 517188, upload-time = "2026-01-30T11:21:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/47/c7/5f80b12ee88ec26e930c3a5a602608a63c29cf60c81a0eb066d583772550/pendulum-3.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e5afc753e570cce1f44197676371f68953f7d4f022303d141bb09f804d5fe6d7", size = 561833, upload-time = "2026-01-30T11:21:19.232Z" }, - { url = "https://files.pythonhosted.org/packages/90/15/1ac481626cb63db751f6281e294661947c1f0321ebe5d1c532a3b51a8006/pendulum-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:fd55c12560816d9122ca2142d9e428f32c0c083bf77719320b1767539c7a3a3b", size = 258725, upload-time = "2026-01-30T11:21:20.558Z" }, - { url = "https://files.pythonhosted.org/packages/40/ae/50b0398d7d027eb70a3e1e336de7b6e599c6b74431cb7d3863287e1292bb/pendulum-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:faef52a7ed99729f0838353b956f3fabf6c550c062db247e9e2fc2b48fcb9457", size = 253089, upload-time = "2026-01-30T11:21:22.497Z" }, + { url = "https://files.pythonhosted.org/packages/27/8c/400c8b8dbd7524424f3d9902ded64741e82e5e321d1aabbd68ade89e71cf/pendulum-3.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:addb0512f919fe5b70c8ee534ee71c775630d3efe567ea5763d92acff857cfc3", size = 337820, upload-time = "2026-01-30T11:21:24.305Z" }, + { url = "https://files.pythonhosted.org/packages/59/38/7c16f26cc55d9206d71da294ce6857d0da381e26bc9e0c2a069424c2b173/pendulum-3.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3aaa50342dc174acebdc21089315012e63789353957b39ac83cac9f9fc8d1075", size = 327551, upload-time = "2026-01-30T11:21:25.747Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cd/f36ec5d56d55104232380fdbf84ff53cc05607574af3cbdc8a43991ac8a7/pendulum-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:927e9c9ab52ff68e71b76dd410e5f1cd78f5ea6e7f0a9f5eb549aea16a4d5354", size = 339894, upload-time = "2026-01-30T11:21:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/b9a1e546519c3a92d5bc17787cea925e06a20def2ae344fa136d2fc40338/pendulum-3.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:249d18f5543c9f43aba3bd77b34864ec8cf6f64edbead405f442e23c94fce63d", size = 373766, upload-time = "2026-01-30T11:21:28.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a6/6471ab87ae2260594501f071586a765fc894817043b7d2d4b04e2eff4f31/pendulum-3.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c644cc15eec5fb02291f0f193195156780fd5a0affd7a349592403826d1a35e", size = 379837, upload-time = "2026-01-30T11:21:30.637Z" }, + { url = "https://files.pythonhosted.org/packages/0d/79/0ba0c14e862388f7b822626e6e989163c23bebe7f96de5ec4b207cbe7c3d/pendulum-3.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:063ab61af953bb56ad5bc8e131fd0431c915ed766d90ccecd7549c8090b51004", size = 348904, upload-time = "2026-01-30T11:21:32.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/34/df922c7c0b12719589d4954bfa5bdca9e02bcde220f5c5c1838a87118960/pendulum-3.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:26a3ae26c9dd70a4256f1c2f51addc43641813574c0db6ce5664f9861cd93621", size = 517173, upload-time = "2026-01-30T11:21:34.428Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/3b9e061eeee97b72a47c1434ee03f6d85f0284d9285d92b12b0fff2d19ac/pendulum-3.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:2b10d91dc00f424444a42f47c69e6b3bfd79376f330179dc06bc342184b35f9a", size = 561744, upload-time = "2026-01-30T11:21:35.861Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7e/f12fdb6070b7975c1fcfa5685dbe4ab73c788878a71f4d1d7e3c87979e37/pendulum-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:63070ff03e30a57b16c8e793ee27da8dac4123c1d6e0cf74c460ce9ee8a64aa4", size = 258746, upload-time = "2026-01-30T11:21:37.782Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/5abd872056357f069ae34a9b24a75ac58e79092d16201d779a8dd31386bb/pendulum-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:c8dde63e2796b62070a49ce813ce200aba9186130307f04ec78affcf6c2e8122", size = 253028, upload-time = "2026-01-30T11:21:39.381Z" }, { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, ] @@ -1694,6 +1672,12 @@ 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, 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, 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/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" }, @@ -1710,32 +1694,17 @@ version = "2.9.12" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, - { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, - { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, - { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, -] - -[[package]] -name = "pyarrow" -version = "24.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, - { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, - { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, ] [[package]] @@ -1771,25 +1740,48 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] [[package]] @@ -1819,25 +1811,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] -[[package]] -name = "pyogrio" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "numpy" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/d4/12f86b1ed09721363da4c09622464b604c851a9223fc0c6b393fb2012208/pyogrio-0.12.1.tar.gz", hash = "sha256:e548ab705bb3e5383693717de1e6c76da97f3762ab92522cb310f93128a75ff1", size = 303289, upload-time = "2025-11-28T19:04:53.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/e0/656b6536549d41b5aec57e0deca1f269b4f17532f0636836f587e581603a/pyogrio-0.12.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:7a0d5ca39184030aec4cde30f4258f75b227a854530d2659babc8189d76e657d", size = 23661857, upload-time = "2025-11-28T19:03:27.744Z" }, - { url = "https://files.pythonhosted.org/packages/14/78/313259e40da728bdb60106ffdc7ea8224d164498cb838ecb79b634aab967/pyogrio-0.12.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:feaff42bbe8087ca0b30e33b09d1ce049ca55fe83ad83db1139ef37d1d04f30c", size = 25237106, upload-time = "2025-11-28T19:03:30.018Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ca/5368571a8b00b941ccfbe6ea29a5566aaffd45d4eb1553b956f7755af43e/pyogrio-0.12.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:81096a5139532de5a8003ef02b41d5d2444cb382a9aecd1165b447eb549180d3", size = 31417048, upload-time = "2025-11-28T19:03:32.572Z" }, - { url = "https://files.pythonhosted.org/packages/ef/85/6eeb875f27bf498d657eb5dab9f58e4c48b36c9037122787abee9a1ba4ba/pyogrio-0.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:41b78863f782f7a113ed0d36a5dc74d59735bd3a82af53510899bb02a18b06bb", size = 30952115, upload-time = "2025-11-28T19:03:35.332Z" }, - { url = "https://files.pythonhosted.org/packages/36/f7/cf8bec9024625947e1a71441906f60a5fa6f9e4c441c4428037e73b1fcc8/pyogrio-0.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8b65be8c4258b27cc8f919b21929cecdadda4c353e3637fa30850339ef4d15c5", size = 32537246, upload-time = "2025-11-28T19:03:37.969Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/7c9f5e428273574e69f217eba3a6c0c42936188ad4dcd9e2c41ebb711188/pyogrio-0.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:1291b866c2c81d991bda15021b08b3621709b40ee3a85689229929e9465788bf", size = 22933980, upload-time = "2025-11-28T19:03:41.047Z" }, -] - [[package]] name = "pyproj" version = "3.7.2" @@ -1847,15 +1820,24 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/ab/9893ea9fb066be70ed9074ae543914a618c131ed8dff2da1e08b3a4df4db/pyproj-3.7.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:0a9bb26a6356fb5b033433a6d1b4542158fb71e3c51de49b4c318a1dff3aeaab", size = 6219832, upload-time = "2025-08-14T12:04:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/53/78/4c64199146eed7184eb0e85bedec60a4aa8853b6ffe1ab1f3a8b962e70a0/pyproj-3.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:567caa03021178861fad27fabde87500ec6d2ee173dd32f3e2d9871e40eebd68", size = 4620650, upload-time = "2025-08-14T12:04:11.978Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ac/14a78d17943898a93ef4f8c6a9d4169911c994e3161e54a7cedeba9d8dde/pyproj-3.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c203101d1dc3c038a56cff0447acc515dd29d6e14811406ac539c21eed422b2a", size = 9667087, upload-time = "2025-08-14T12:04:13.964Z" }, - { url = "https://files.pythonhosted.org/packages/b8/be/212882c450bba74fc8d7d35cbd57e4af84792f0a56194819d98106b075af/pyproj-3.7.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1edc34266c0c23ced85f95a1ee8b47c9035eae6aca5b6b340327250e8e281630", size = 9552797, upload-time = "2025-08-14T12:04:16.624Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c0/c0f25c87b5d2a8686341c53c1792a222a480d6c9caf60311fec12c99ec26/pyproj-3.7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa9f26c21bc0e2dc3d224cb1eb4020cf23e76af179a7c66fea49b828611e4260", size = 10837036, upload-time = "2025-08-14T12:04:18.733Z" }, - { url = "https://files.pythonhosted.org/packages/5d/37/5cbd6772addde2090c91113332623a86e8c7d583eccb2ad02ea634c4a89f/pyproj-3.7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9428b318530625cb389b9ddc9c51251e172808a4af79b82809376daaeabe5e9", size = 10775952, upload-time = "2025-08-14T12:04:20.709Z" }, - { url = "https://files.pythonhosted.org/packages/69/a1/dc250e3cf83eb4b3b9a2cf86fdb5e25288bd40037ae449695550f9e96b2f/pyproj-3.7.2-cp312-cp312-win32.whl", hash = "sha256:b3d99ed57d319da042f175f4554fc7038aa4bcecc4ac89e217e350346b742c9d", size = 5898872, upload-time = "2025-08-14T12:04:22.485Z" }, - { url = "https://files.pythonhosted.org/packages/4a/a6/6fe724b72b70f2b00152d77282e14964d60ab092ec225e67c196c9b463e5/pyproj-3.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:11614a054cd86a2ed968a657d00987a86eeb91fdcbd9ad3310478685dc14a128", size = 6312176, upload-time = "2025-08-14T12:04:24.736Z" }, - { url = "https://files.pythonhosted.org/packages/5d/68/915cc32c02a91e76d02c8f55d5a138d6ef9e47a0d96d259df98f4842e558/pyproj-3.7.2-cp312-cp312-win_arm64.whl", hash = "sha256:509a146d1398bafe4f53273398c3bb0b4732535065fa995270e52a9d3676bca3", size = 6233452, upload-time = "2025-08-14T12:04:27.287Z" }, + { url = "https://files.pythonhosted.org/packages/be/14/faf1b90d267cea68d7e70662e7f88cefdb1bc890bd596c74b959e0517a72/pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1", size = 6214580, upload-time = "2025-08-14T12:04:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/da9a45b184d375f62667f62eba0ca68569b0bd980a0bb7ffcc1d50440520/pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7", size = 4615388, upload-time = "2025-08-14T12:04:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e7/d2b459a4a64bca328b712c1b544e109df88e5c800f7c143cfbc404d39bfb/pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa", size = 9628455, upload-time = "2025-08-14T12:04:32.435Z" }, + { url = "https://files.pythonhosted.org/packages/f8/85/c2b1706e51942de19076eff082f8495e57d5151364e78b5bef4af4a1d94a/pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681", size = 9514269, upload-time = "2025-08-14T12:04:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/34/38/07a9b89ae7467872f9a476883a5bad9e4f4d1219d31060f0f2b282276cbe/pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5", size = 10808437, upload-time = "2025-08-14T12:04:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/fda1daeabbd39dec5b07f67233d09f31facb762587b498e6fc4572be9837/pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67", size = 10745540, upload-time = "2025-08-14T12:04:38.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/c793182cbba65a39a11db2ac6b479fe76c59e6509ae75e5744c344a0da9d/pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3", size = 5896506, upload-time = "2025-08-14T12:04:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/be/0f/747974129cf0d800906f81cd25efd098c96509026e454d4b66868779ab04/pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7", size = 6310195, upload-time = "2025-08-14T12:04:42.974Z" }, + { url = "https://files.pythonhosted.org/packages/82/64/fc7598a53172c4931ec6edf5228280663063150625d3f6423b4c20f9daff/pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100", size = 6230748, upload-time = "2025-08-14T12:04:44.491Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f0/611dd5cddb0d277f94b7af12981f56e1441bf8d22695065d4f0df5218498/pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279", size = 6241729, upload-time = "2025-08-14T12:04:46.274Z" }, + { url = "https://files.pythonhosted.org/packages/15/93/40bd4a6c523ff9965e480870611aed7eda5aa2c6128c6537345a2b77b542/pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6", size = 4652497, upload-time = "2025-08-14T12:04:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/7150ead53c117880b35e0d37960d3138fe640a235feb9605cb9386f50bb0/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220", size = 9942610, upload-time = "2025-08-14T12:04:49.652Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/7a4a7eafecf2b46ab64e5c08176c20ceb5844b503eaa551bf12ccac77322/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c", size = 9692390, upload-time = "2025-08-14T12:04:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/c3/55/ae18f040f6410f0ea547a21ada7ef3e26e6c82befa125b303b02759c0e9d/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c", size = 11047596, upload-time = "2025-08-14T12:04:53.748Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2e/d3fff4d2909473f26ae799f9dda04caa322c417a51ff3b25763f7d03b233/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69", size = 10896975, upload-time = "2025-08-14T12:04:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bc/8fc7d3963d87057b7b51ebe68c1e7c51c23129eee5072ba6b86558544a46/pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2", size = 5953057, upload-time = "2025-08-14T12:04:58.466Z" }, + { url = "https://files.pythonhosted.org/packages/cc/27/ea9809966cc47d2d51e6d5ae631ea895f7c7c7b9b3c29718f900a8f7d197/pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3", size = 6375414, upload-time = "2025-08-14T12:04:59.861Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/1ef0129fba9a555c658e22af68989f35e7ba7b9136f25758809efec0cd6e/pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd", size = 6262501, upload-time = "2025-08-14T12:05:01.39Z" }, ] [[package]] @@ -1956,16 +1938,40 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/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" }, +] + +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, ] [[package]] @@ -1975,7 +1981,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, - { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -2042,21 +2047,35 @@ version = "0.30.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, ] [[package]] @@ -2090,35 +2109,26 @@ version = "1.3.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, - { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, - { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, - { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, - { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, - { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, - { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, -] - -[[package]] -name = "shapely" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, - { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, - { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, - { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, ] [[package]] @@ -2158,13 +2168,19 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, + { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, + { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, + { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, ] @@ -2173,18 +2189,6 @@ asyncio = [ { name = "greenlet" }, ] -[[package]] -name = "sqlalchemy-jsonfield" -version = "1.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d0/77/88de5c9ac1a44db1abb493d9d0995681b200ad625d80a4a289c7be438d80/SQLAlchemy-JSONField-1.0.2.tar.gz", hash = "sha256:dab3abc9d75a1640e7f3d4875564a4199f665d27863da8d5a089e4eaca5e67f2", size = 15879, upload-time = "2023-11-22T09:31:22.468Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/67/d75d119e70863e0519c8eec5fc66714d34ad1ee9e5e73bf4fc8e3d259fac/SQLAlchemy_JSONField-1.0.2-py3-none-any.whl", hash = "sha256:b2945fa1e60b07d5764a7c73b18da427948b35dd4c07c0e94939001dc2dacf77", size = 10217, upload-time = "2023-11-22T09:31:20.83Z" }, -] - [[package]] name = "sqlalchemy-utils" version = "0.42.1" @@ -2208,15 +2212,14 @@ wheels = [ [[package]] name = "starlette" -version = "0.48.0" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, ] [[package]] @@ -2332,14 +2335,15 @@ wheels = [ [[package]] name = "universal-pathlib" -version = "0.2.6" +version = "0.3.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fsspec" }, + { name = "pathlib-abc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/21/dd871495af3933e585261adce42678dcdf1168c9d6fa0a8f7b6565e54472/universal_pathlib-0.2.6.tar.gz", hash = "sha256:50817aaeaa9f4163cb1e76f5bdf84207fa05ce728b23fd779479b3462e5430ac", size = 175427, upload-time = "2024-12-13T00:58:27.514Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/6e/d997a70ee8f4c61f9a7e2f4f8af721cf072a3326848fc881b05187e52558/universal_pathlib-0.3.10.tar.gz", hash = "sha256:4487cbc90730a48cfb64f811d99e14b6faed6d738420cd5f93f59f48e6930bfb", size = 261110, upload-time = "2026-02-22T14:40:58.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/4d/2e577f6db7aa0f932d19f799c18f604b2b302c65f733419b900ec07dbade/universal_pathlib-0.2.6-py3-none-any.whl", hash = "sha256:700dec2b58ef34b87998513de6d2ae153b22f083197dfafb8544744edabd1b18", size = 50087, upload-time = "2024-12-13T00:58:24.582Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1a/5d9a402b39ec892d856bbdd9db502ff73ce28cdf4aff72eb1ce1d6843506/universal_pathlib-0.3.10-py3-none-any.whl", hash = "sha256:dfaf2fb35683d2eb1287a3ed7b215e4d6016aa6eaf339c607023d22f90821c66", size = 83528, upload-time = "2026-02-22T14:40:57.316Z" }, ] [[package]] @@ -2390,12 +2394,12 @@ version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, ] [[package]] @@ -2407,19 +2411,29 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, ] [[package]] @@ -2428,15 +2442,15 @@ version = "16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] @@ -2470,17 +2484,28 @@ version = "2.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, - { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, - { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, - { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, - { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, - { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ] diff --git a/docker/Dockerfile.airflow b/docker/Dockerfile.airflow index 6affe9a7..fb427d88 100644 --- a/docker/Dockerfile.airflow +++ b/docker/Dockerfile.airflow @@ -1,7 +1,12 @@ # syntax=docker/dockerfile:1 -ARG AIRFLOW_VERSION=3.1.8 +ARG AIRFLOW_VERSION=3.2.2 ARG UV_VERSION=0.9.15 +# Debian Trixie based Apache Airflow image, built locally from the official +# Airflow Dockerfile (docker/airflow-base) since apache/airflow only ships +# bookworm based images. Build it with `make build-airflow-base`. +ARG AIRFLOW_BASE_IMAGE=datafeeder-airflow-base:${AIRFLOW_VERSION}-trixie + # Named stage for the uv binary so later `COPY --from=uv` works on BuildKit # versions that don't support variable expansion in `--from`. FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv @@ -11,14 +16,14 @@ FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv # root workspace, which can't satisfy airflow's fastapi cap alongside backend) # and build the data_manipulation wheel. # ============================================================================ -FROM python:3.12-slim AS builder +FROM python:3.13-slim-trixie AS builder COPY --from=uv /uv /uvx /bin/ ENV UV_LINK_MODE=copy \ UV_COMPILE_BYTECODE=1 \ UV_PYTHON_DOWNLOADS=never \ - UV_PYTHON=python3.12 \ + UV_PYTHON=python3.13 \ UV_CACHE_DIR=/tmp/uv-cache \ UV_NO_CACHE=1 \ UV_FROZEN=1 @@ -33,12 +38,13 @@ RUN cd apps/elt \ && uv build --wheel ../../libs/data_manipulation -o /tmp/wheels # ============================================================================ -# Base stage: airflow image + uv, shared by development and production +# Base stage: Trixie based airflow image + uv, shared by development and +# production. The base image is built from the official Airflow Dockerfile +# (see docker/airflow-base and `make build-airflow-base`) on debian:trixie-slim +# with the system Python 3.13 shipped by debian:trixie-slim, matching the +# workspace's python pin. # ============================================================================ -# The `-python3.12` suffix matches the workspace's `requires-python` pin. -# Without it, apache/airflow defaults to 3.13, which breaks lockfile-resolved -# wheels (uv falls back to source builds against a toolchain-less image). -FROM apache/airflow:${AIRFLOW_VERSION}-python3.12 AS base +FROM ${AIRFLOW_BASE_IMAGE} AS base # Switch to root so we can install uv into /bin and run uv pip install --system # against the airflow interpreter. Each leaf stage drops back to USER airflow, @@ -50,7 +56,7 @@ COPY --from=uv /uv /uvx /bin/ # from uv.lock; UV_NO_CACHE keeps the layer small since the cache is single-use. # UV_PYTHON points at the airflow image's venv interpreter so `uv pip install # --system` installs alongside the preinstalled airflow + provider distribution -# rather than failing to find a system python3.12. +# rather than failing to find a system python3.13. ENV UV_LINK_MODE=copy \ UV_COMPILE_BYTECODE=1 \ UV_PYTHON_DOWNLOADS=never \ @@ -59,6 +65,37 @@ ENV UV_LINK_MODE=copy \ UV_NO_CACHE=1 \ UV_FROZEN=1 +# GDAL > 3.9 from conda-forge. Bookworm only ships 3.6, so we install GDAL into +# an isolated prefix via micromamba and expose the CLI through small wrapper +# scripts (not bare symlinks). The wrappers force ${GDAL_PREFIX}/lib to the front +# of LD_LIBRARY_PATH so the conda libstdc++/libproj/... always win: libgdal.so +# carries no RUNPATH of its own, so any LD_LIBRARY_PATH inherited from the airflow +# worker would otherwise drag in bookworm's too-old libstdc++ (missing +# GLIBCXX_3.4.31 / CXXABI_1.3.15). We deliberately keep ${GDAL_PREFIX}/bin off the +# global PATH so conda's python never shadows the airflow interpreter. +# conda-forge splits GDAL drivers into per-format packages on top of +# libgdal-core: libgdal-arrow-parquet provides the (Geo)Parquet driver and +# libgdal-pg provides the live PostgreSQL/PostGIS driver (ogr_PG.so) that the +# ELT uses to write into PostGIS via `ogr2ogr -f PostgreSQL`. Without it core +# only ships PGDUMP (write-only SQL dump), not the network driver. +ENV GDAL_PREFIX=/opt/gdal +RUN --mount=type=cache,target=/opt/conda-cache,sharing=locked \ + curl -Ls https://github.com/mamba-org/micromamba-releases/releases/latest/download/micromamba-linux-64 \ + -o /usr/local/bin/micromamba \ + && chmod +x /usr/local/bin/micromamba \ + && MAMBA_ROOT_PREFIX=/opt/conda-cache micromamba create -y -p ${GDAL_PREFIX} \ + -c conda-forge 'gdal>=3.12' libgdal-arrow-parquet libgdal-pg \ + && for b in ogr2ogr ogrinfo gdalinfo gdal_translate gdalwarp gdalsrsinfo; do \ + printf '#!/bin/sh\nexport LD_LIBRARY_PATH="%s/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"\nexec "%s/bin/%s" "$@"\n' \ + "${GDAL_PREFIX}" "${GDAL_PREFIX}" "$b" > /usr/local/bin/$b \ + && chmod +x /usr/local/bin/$b; \ + done \ + && find ${GDAL_PREFIX} -name '*.a' -delete \ + && rm -rf ${GDAL_PREFIX}/pkgs + +ENV GDAL_DATA=${GDAL_PREFIX}/share/gdal \ + PROJ_DATA=${GDAL_PREFIX}/share/proj + # ============================================================================ # Development stage: deps only. libs/data_manipulation is bind-mounted under # /opt/airflow/dags by compose.airflow.yaml for hot-reload, so we don't install @@ -73,14 +110,6 @@ COPY --from=builder /tmp/requirements.txt /tmp/requirements.txt RUN uv pip install --system -r /tmp/requirements.txt \ && rm -f /tmp/requirements.txt -# Runtime deps nécessaires pour GDAL -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - apt-get update \ - && apt-get install -y --no-install-recommends \ - gdal-bin libgdal-dev \ - && rm -rf /var/lib/apt/lists/* - USER airflow # ============================================================================ diff --git a/docker/airflow-base/Dockerfile b/docker/airflow-base/Dockerfile new file mode 100644 index 00000000..023ecd9c --- /dev/null +++ b/docker/airflow-base/Dockerfile @@ -0,0 +1,2293 @@ +# syntax=docker/dockerfile:1.4 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# THIS DOCKERFILE IS INTENDED FOR PRODUCTION USE AND DEPLOYMENT. +# NOTE! IT IS ALPHA-QUALITY FOR NOW - WE ARE IN A PROCESS OF TESTING IT +# +# +# This is a multi-segmented image. It actually contains two images: +# +# airflow-build-image - there all airflow dependencies can be installed (and +# built - for those dependencies that require +# build essentials). Airflow is installed there with +# ${HOME}/.local virtualenv which is also considered +# As --user folder by python when creating venv with +# --system-site-packages +# +# main - this is the actual production image that is much +# smaller because it does not contain all the build +# essentials. Instead the ${HOME}/.local folder +# is copied from the build-image - this way we have +# only result of installation and we do not need +# all the build essentials. This makes the image +# much smaller. +# +# Use the same builder frontend version for everyone +ARG AIRFLOW_EXTRAS="aiobotocore,amazon,async,celery,cncf-kubernetes,common-io,common-messaging,docker,elasticsearch,fab,ftp,git,google,google-auth,graphviz,grpc,hashicorp,http,ldap,microsoft-azure,mysql,odbc,openlineage,pandas,postgres,redis,sendgrid,sftp,slack,snowflake,ssh,statsd,uv" +ARG ADDITIONAL_AIRFLOW_EXTRAS="" +ARG ADDITIONAL_PYTHON_DEPS="" + +ARG AIRFLOW_HOME=/opt/airflow +ARG AIRFLOW_IMAGE_TYPE="prod" +ARG AIRFLOW_UID="50000" +ARG AIRFLOW_USER_HOME_DIR=/home/airflow + +# latest released version here +ARG AIRFLOW_VERSION="3.2.2" + +ARG BASE_IMAGE="debian:trixie-slim" +ARG AIRFLOW_PYTHON_VERSION="3.13.13" + +# PYTHON_LTO: Controls whether Python is built with Link-Time Optimization (LTO). +# +# Link-Time Optimization uses MD5 checksums during the compilation process to verify +# object files and intermediate representations. In FIPS-compliant environments, MD5 +# is blocked as it's not an approved cryptographic algorithm (see FIPS 140-2/140-3). +# This can cause Python builds with LTO to fail when FIPS mode is enabled. +# +# When building FIPS-compliant images, set this to "false" to disable LTO: +# docker build --build-arg PYTHON_LTO="false" ... +# +# Default: "true" (LTO enabled for better performance) +# +# Related: https://github.com/apache/airflow/issues/58337 +ARG PYTHON_LTO="true" + +# You can swap comments between those two args to test pip from the main version +# When you attempt to test if the version of `pip` from specified branch works for our builds +# Also use `force pip` label on your PR to swap all places we use `uv` to `pip` +ARG AIRFLOW_PIP_VERSION=26.1.2 +# ARG AIRFLOW_PIP_VERSION="git+https://github.com/pypa/pip.git@main" +ARG AIRFLOW_UV_VERSION=0.11.19 +ARG AIRFLOW_USE_UV="false" +ARG AIRFLOW_IMAGE_REPOSITORY="https://github.com/apache/airflow" +ARG AIRFLOW_IMAGE_README_URL="https://raw.githubusercontent.com/apache/airflow/main/docs/docker-stack/README.md" + +# By default we install latest airflow from PyPI so we do not need to copy sources of Airflow +# from the host - so we are using Dockerfile and copy it to /Dockerfile in target image +# because this is the only file we know exists locally. This way you can build the image in PyPI with +# **just** the Dockerfile and no need for any other files from Airflow repository. +# However, in case of breeze/development use we use latest sources and we override those +# SOURCES_FROM/TO with "." and "/opt/airflow" respectively - so that sources of Airflow (and all providers) +# are used to build the PROD image used in tests. +ARG AIRFLOW_SOURCES_FROM="Dockerfile" +ARG AIRFLOW_SOURCES_TO="/Dockerfile" + +# By default latest released version of airflow is installed (when empty) but this value can be overridden +# and we can install version according to specification (For example ==2.0.2 or <3.0.0). +ARG AIRFLOW_VERSION_SPECIFICATION="" + +# By default PIP has progress bar but you can disable it. +ARG PIP_PROGRESS_BAR="on" + +############################################################################################## +# This is the script image where we keep all inlined bash scripts needed in other segments +############################################################################################## +FROM scratch as scripts + +############################################################################################## +# Please DO NOT modify the inlined scripts manually. The content of those files will be +# replaced by prek automatically from the "scripts/docker/" folder. +# This is done in order to avoid problems with caching and file permissions and in order to +# make the PROD Dockerfile standalone +############################################################################################## + +# The content below is automatically copied from scripts/docker/install_os_dependencies.sh +COPY <<"EOF" /install_os_dependencies.sh +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" != 1 ]]; then + echo + echo "ERROR! There should be 'runtime', 'ci' or 'dev' parameter passed as argument.". + echo + exit 1 +fi + +AIRFLOW_PYTHON_VERSION=${AIRFLOW_PYTHON_VERSION:-3.10.18} +PYTHON_LTO=${PYTHON_LTO:-true} +GOLANG_MAJOR_MINOR_VERSION=${GOLANG_MAJOR_MINOR_VERSION:-1.24.4} +RUSTUP_DEFAULT_TOOLCHAIN=${RUSTUP_DEFAULT_TOOLCHAIN:-stable} +RUSTUP_VERSION=${RUSTUP_VERSION:-1.29.0} +COSIGN_VERSION=${COSIGN_VERSION:-3.0.5} + +if [[ "${1}" == "runtime" ]]; then + INSTALLATION_TYPE="RUNTIME" +elif [[ "${1}" == "dev" ]]; then + INSTALLATION_TYPE="DEV" +elif [[ "${1}" == "ci" ]]; then + INSTALLATION_TYPE="CI" +else + echo + echo "ERROR! Wrong argument. Passed ${1} and it should be one of 'runtime', 'ci' or 'dev'.". + echo + exit 1 +fi + +function get_dev_apt_deps() { + if [[ "${DEV_APT_DEPS=}" == "" ]]; then + DEV_APT_DEPS="\ +apt-transport-https \ +apt-utils \ +build-essential \ +dirmngr \ +freetds-bin \ +freetds-dev \ +git \ +graphviz \ +graphviz-dev \ +krb5-user \ +ldap-utils \ +libbluetooth-dev \ +libbz2-dev \ +libc6-dev \ +libdb-dev \ +libev-dev \ +libev4 \ +libffi-dev \ +libgdbm-compat-dev \ +libgdbm-dev \ +libgeos-dev \ +libkrb5-dev \ +libldap2-dev \ +libleveldb-dev \ +libleveldb1d \ +liblzma-dev \ +libncurses-dev \ +libreadline-dev \ +libsasl2-2 \ +libsasl2-dev \ +libsasl2-modules \ +libsqlite3-dev \ +libssl-dev \ +libxmlsec1 \ +libxmlsec1-dev \ +libzstd-dev \ +locales \ +lsb-release \ +lzma \ +openssh-client \ +openssl \ +pkg-config \ +pkgconf \ +sasl2-bin \ +sqlite3 \ +sudo \ +tk-dev \ +unixodbc \ +unixodbc-dev \ +uuid-dev \ +wget \ +xz-utils \ +zlib1g-dev \ +" + export DEV_APT_DEPS + fi +} + +function get_runtime_apt_deps() { + local debian_version + local debian_version_apt_deps + # Get debian version without installing lsb_release + # shellcheck disable=SC1091 + debian_version=$(. /etc/os-release; printf '%s\n' "$VERSION_CODENAME";) + echo + echo "DEBIAN CODENAME: ${debian_version}" + echo + debian_version_apt_deps="\ +libffi8 \ +libldap2 \ +libssl3 \ +netcat-openbsd\ +" + echo + echo "APPLIED INSTALLATION CONFIGURATION FOR DEBIAN VERSION: ${debian_version}" + echo + if [[ "${RUNTIME_APT_DEPS=}" == "" ]]; then + RUNTIME_APT_DEPS="\ +${debian_version_apt_deps} \ +apt-transport-https \ +apt-utils \ +curl \ +dumb-init \ +freetds-bin \ +git \ +gnupg \ +iputils-ping \ +krb5-user \ +ldap-utils \ +libev4 \ +libgeos-dev \ +libsasl2-2 \ +libsasl2-modules \ +libxmlsec1 \ +locales \ +lsb-release \ +openssh-client \ +python3 \ +python3-pip \ +python3-venv \ +rsync \ +sasl2-bin \ +sqlite3 \ +sudo \ +unixodbc \ +wget\ +" + export RUNTIME_APT_DEPS + fi +} + +function install_docker_cli() { + apt-get update + apt-get install ca-certificates curl + install -m 0755 -d /etc/apt/keyrings + curl -fsSL --retry 3 --retry-delay 5 https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + # shellcheck disable=SC1091 + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + tee /etc/apt/sources.list.d/docker.list > /dev/null + apt-get update + apt-get install -y --no-install-recommends docker-ce-cli +} + +function install_debian_dev_dependencies() { + apt-get update + apt-get install -yqq --no-install-recommends apt-utils >/dev/null 2>&1 + apt-get install -y --no-install-recommends wget curl gnupg2 lsb-release ca-certificates + # shellcheck disable=SC2086 + export ${ADDITIONAL_DEV_APT_ENV?} + if [[ ${DEV_APT_COMMAND} != "" ]]; then + bash -o pipefail -o errexit -o nounset -o nolog -c "${DEV_APT_COMMAND}" + fi + if [[ ${ADDITIONAL_DEV_APT_COMMAND} != "" ]]; then + bash -o pipefail -o errexit -o nounset -o nolog -c "${ADDITIONAL_DEV_APT_COMMAND}" + fi + apt-get update + local debian_version + local debian_version_apt_deps + # Get debian version without installing lsb_release + # shellcheck disable=SC1091 + debian_version=$(. /etc/os-release; printf '%s\n' "$VERSION_CODENAME";) + echo + echo "DEBIAN CODENAME: ${debian_version}" + echo + # shellcheck disable=SC2086 + apt-get install -y --no-install-recommends ${DEV_APT_DEPS} +} + +function install_additional_dev_dependencies() { + if [[ "${ADDITIONAL_DEV_APT_DEPS=}" != "" ]]; then + # shellcheck disable=SC2086 + apt-get install -y --no-install-recommends ${ADDITIONAL_DEV_APT_DEPS} + fi +} + +function link_python() { + # link python binaries to /usr/local/bin and /usr/python/bin with and without 3 suffix + # Links in /usr/local/bin are needed for tools that expect python to be there + # Links in /usr/python/bin are needed for tools that are detecting home of python installation including + # lib/site-packages. The /usr/python/bin should be first in PATH in order to help with the last part. + for dst in pip3 python3 python3-config; do + src="$(echo "${dst}" | tr -d 3)" + echo "Linking ${dst} in /usr/local/bin and /usr/python/bin" + ln -sv "/usr/python/bin/${dst}" "/usr/local/bin/${dst}" + for dir in /usr/local/bin /usr/python/bin; do + if [[ ! -e "${dir}/${src}" ]]; then + echo "Creating ${src} - > ${dst} link in ${dir}" + ln -sv "${dir}/${dst}" "${dir}/${src}" + fi + done + done + for dst in /usr/python/lib/* + do + src="/usr/local/lib/$(basename "${dst}")" + if [[ -e "${src}" ]]; then + rm -rf "${src}" + fi + echo "Linking ${dst} to ${src}" + ln -sv "${dst}" "${src}" + done + ldconfig +} + +function install_debian_runtime_dependencies() { + apt-get update + apt-get install --no-install-recommends -yqq apt-utils >/dev/null 2>&1 + apt-get install -y --no-install-recommends wget curl gnupg2 lsb-release ca-certificates + # shellcheck disable=SC2086 + export ${ADDITIONAL_RUNTIME_APT_ENV?} + if [[ "${RUNTIME_APT_COMMAND}" != "" ]]; then + bash -o pipefail -o errexit -o nounset -o nolog -c "${RUNTIME_APT_COMMAND}" + fi + if [[ "${ADDITIONAL_RUNTIME_APT_COMMAND}" != "" ]]; then + bash -o pipefail -o errexit -o nounset -o nolog -c "${ADDITIONAL_RUNTIME_APT_COMMAND}" + fi + apt-get update + # shellcheck disable=SC2086 + apt-get install -y --no-install-recommends ${RUNTIME_APT_DEPS} ${ADDITIONAL_RUNTIME_APT_DEPS} + apt-get autoremove -yqq --purge + apt-get clean + link_python + rm -rf /var/lib/apt/lists/* /var/log/* +} + +function install_cosign() { + local arch + arch="$(dpkg --print-architecture)" + declare -A cosign_sha256s=( + # https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign_checksums.txt + [amd64]="db15cc99e6e4837daabab023742aaddc3841ce57f193d11b7c3e06c8003642b2" + [arm64]="d098f3168ae4b3aa70b4ca78947329b953272b487727d1722cb3cb098a1a20ab" + ) + local cosign_sha256="${cosign_sha256s[${arch}]}" + if [[ -z "${cosign_sha256}" ]]; then + echo "Unsupported architecture for cosign: ${arch}" + exit 1 + fi + curl -fsSL --retry 3 --retry-delay 5 \ + "https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-${arch}" \ + -o /tmp/cosign + echo "${cosign_sha256} /tmp/cosign" | sha256sum --check + chmod +x /tmp/cosign +} + +function install_python() { + # OPTION 2: use Debian's system Python instead of building it from source. + # Debian trixie already ships Python 3.13, so we install it from apt and + # recreate the /usr/python layout (via symlinks) that the rest of the image + # expects (PATH, LD_LIBRARY_PATH and the COPY into the final image). + echo + echo "Installing Python from Debian packages (no source build)..." + echo + apt-get update + apt-get install -y --no-install-recommends \ + python3 python3-dev python3-venv python3-pip libpython3-dev + # Debian marks the system Python as PEP 668 "externally-managed", which blocks + # `pip install` outside a venv. The original from-source build had no such marker, + # so remove it to keep the existing packaging-tools bootstrap working. + rm -f /usr/lib/python3.*/EXTERNALLY-MANAGED + local arch_triplet + arch_triplet="$(dpkg-architecture -qDEB_HOST_MULTIARCH)" + mkdir -p /usr/python/bin /usr/python/lib + for f in python3 python3-config pip3; do + [[ -e "/usr/bin/${f}" ]] && ln -sfv "/usr/bin/${f}" "/usr/python/bin/${f}" + done + ln -sfv /usr/bin/python3 /usr/python/bin/python + ln -sfv /usr/bin/pip3 /usr/python/bin/pip || true + ln -sfv /usr/bin/python3 /usr/local/bin/python + ln -sfv /usr/bin/pip3 /usr/local/bin/pip || true + for lib in /usr/lib/"${arch_triplet}"/libpython3*.so*; do + [[ -e "${lib}" ]] && ln -sfv "${lib}" "/usr/python/lib/$(basename "${lib}")" + done + ldconfig + return 0 + + # --- Original from-source Python build kept below (disabled by the early return above) --- + # If system python (3.11 in bookworm) is installed (via automatic installation of some dependencies for example), we need + # to fail and make sure that it is not there, because there can be strange interactions if we install + # newer version and system libraries are installed, because + # when you create a virtualenv part of the shared libraries of Python can be taken from the system + # Installation leading to weird errors when you want to install some modules - for example when you install ssl: + # /usr/python/lib/python3.11/lib-dynload/_ssl.cpython-311-aarch64-linux-gnu.so: undefined symbol: _PyModule_Add + if dpkg -l | grep '^ii' | grep '^ii libpython' >/dev/null; then + echo + echo "ERROR! System python is installed by one of the previous steps" + echo + installed_libpython=$(dpkg -l | awk '/^ii libpython3/{print $2; exit}') + echo "Please make sure that no python packages are installed by default. Displaying the reason why ${installed_libpython} is installed:" + echo + apt-get install -yqq aptitude >/dev/null + aptitude why "${installed_libpython}" + echo + exit 1 + else + echo + echo "GOOD! System python is not installed - OK" + echo + fi + wget --tries=3 --waitretry=5 -O python.tar.xz "https://www.python.org/ftp/python/${AIRFLOW_PYTHON_VERSION%%[a-z]*}/Python-${AIRFLOW_PYTHON_VERSION}.tar.xz" + local major_minor_version + major_minor_version="${AIRFLOW_PYTHON_VERSION%.*}" + local major minor + major="${major_minor_version%.*}" + minor="${major_minor_version#*.}" + echo "Verifying Python ${AIRFLOW_PYTHON_VERSION} (${major_minor_version})" + if [[ "${major}" -gt 3 ]] || [[ "${major}" -eq 3 && "${minor}" -ge 11 ]]; then + # Sigstore verification for Python >= 3.11 (PEP 761) + declare -A sigstore_identities=( + # https://peps.python.org/pep-0664/#release-manager-and-crew + [3.11]="pablogsal@python.org" + # https://peps.python.org/pep-0693/#release-manager-and-crew + [3.12]="thomas@python.org" + # https://peps.python.org/pep-0719/#release-manager-and-crew + [3.13]="thomas@python.org" + # https://peps.python.org/pep-0745/#release-manager-and-crew + [3.14]="hugo@python.org" + ) + declare -A sigstore_issuers=( + [3.11]="https://accounts.google.com" + [3.12]="https://accounts.google.com" + [3.13]="https://accounts.google.com" + [3.14]="https://github.com/login/oauth" + ) + wget --tries=3 --waitretry=5 -O python.tar.xz.sigstore \ + "https://www.python.org/ftp/python/${AIRFLOW_PYTHON_VERSION%%[a-z]*}/Python-${AIRFLOW_PYTHON_VERSION}.tar.xz.sigstore" + install_cosign + local identity="${sigstore_identities[${major_minor_version}]}" + local issuer="${sigstore_issuers[${major_minor_version}]}" + /tmp/cosign verify-blob \ + --bundle python.tar.xz.sigstore \ + --certificate-identity "${identity}" \ + --certificate-oidc-issuer "${issuer}" \ + python.tar.xz + rm -f python.tar.xz.sigstore /tmp/cosign + else + # PGP verification for Python 3.10 + declare -A keys=( + # gpg: key 64E628F8D684696D: public key "Pablo Galindo Salgado " imported + # https://peps.python.org/pep-0619/#release-manager-and-crew + [3.10]="A035C8C19219BA821ECEA86B64E628F8D684696D" + ) + wget --tries=3 --waitretry=5 -O python.tar.xz.asc \ + "https://www.python.org/ftp/python/${AIRFLOW_PYTHON_VERSION%%[a-z]*}/Python-${AIRFLOW_PYTHON_VERSION}.tar.xz.asc" + GNUPGHOME="$(mktemp -d)"; export GNUPGHOME + local gpg_key="${keys[${major_minor_version}]}" + echo "Using GPG key ${gpg_key}" + gpg --batch --import "/scripts/docker/keys/python-${major_minor_version}.asc" + gpg --batch --verify python.tar.xz.asc python.tar.xz + gpgconf --kill all + rm -rf "${GNUPGHOME}" python.tar.xz.asc + fi + mkdir -p /usr/src/python + tar --extract --directory /usr/src/python --strip-components=1 --file python.tar.xz + rm python.tar.xz + cd /usr/src/python + arch="$(dpkg --print-architecture)"; arch="${arch##*-}" + gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)" + EXTRA_CFLAGS="$(dpkg-buildflags --get CFLAGS)" + EXTRA_CFLAGS="${EXTRA_CFLAGS:-} -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer"; + LDFLAGS="$(dpkg-buildflags --get LDFLAGS)" + LDFLAGS="${LDFLAGS:--Wl},--strip-all" + # Link-Time Optimization (LTO) uses MD5 checksums for object file verification during + # compilation. In FIPS mode, MD5 is blocked as a non-approved algorithm, causing builds + # to fail. The PYTHON_LTO variable allows disabling LTO for FIPS-compliant builds. + # See: https://github.com/apache/airflow/issues/58337 + local lto_option="" + if [[ "${PYTHON_LTO:-true}" == "true" ]]; then + lto_option="--with-lto" + fi + local build_log + build_log=$(mktemp) + echo "Building Python ${AIRFLOW_PYTHON_VERSION} from source..." + if ! ( + ./configure --enable-optimizations --prefix=/usr/python/ --with-ensurepip --build="$gnuArch" \ + --enable-loadable-sqlite-extensions --enable-option-checking=fatal \ + --enable-shared ${lto_option} && \ + make -s -j "$(nproc)" "EXTRA_CFLAGS=${EXTRA_CFLAGS:-}" \ + "LDFLAGS=${LDFLAGS:--Wl},-rpath='\$\$ORIGIN/../lib'" python && \ + make -s -j "$(nproc)" install + ) > "${build_log}" 2>&1; then + echo + echo "ERROR! Python build failed. Build output:" + echo + cat "${build_log}" + rm -f "${build_log}" + exit 1 + fi + rm -f "${build_log}" + cd / + rm -rf /usr/src/python + find /usr/python -depth \ + \( \ + \( -type d -a \( -name test -o -name tests -o -name idle_test \) \) \ + -o \( -type f -a \( -name 'libpython*.a' \) \) \ + \) -exec rm -rf '{}' + + link_python +} + +function install_golang() { + curl --retry 3 --retry-delay 5 "https://dl.google.com/go/go${GOLANG_MAJOR_MINOR_VERSION}.linux-$(dpkg --print-architecture).tar.gz" -o "go${GOLANG_MAJOR_MINOR_VERSION}.linux.tar.gz" + rm -rf /usr/local/go && tar -C /usr/local -xzf go"${GOLANG_MAJOR_MINOR_VERSION}".linux.tar.gz +} + +function install_rustup() { + local arch + arch="$(dpkg --print-architecture)" + declare -A rustup_targets=( + [amd64]="x86_64-unknown-linux-gnu" + [arm64]="aarch64-unknown-linux-gnu" + ) + declare -A rustup_sha256s=( + # https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/{target}/rustup-init.sha256 + [amd64]="4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10" + [arm64]="9732d6c5e2a098d3521fca8145d826ae0aaa067ef2385ead08e6feac88fa5792" + ) + local target="${rustup_targets[${arch}]}" + local rustup_sha256="${rustup_sha256s[${arch}]}" + if [[ -z "${target}" ]]; then + echo "Unsupported architecture for rustup: ${arch}" + exit 1 + fi + curl --proto '=https' --tlsv1.2 -sSf --retry 3 --retry-delay 5 \ + "https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/${target}/rustup-init" \ + -o /tmp/rustup-init + echo "${rustup_sha256} /tmp/rustup-init" | sha256sum --check + chmod +x /tmp/rustup-init + /tmp/rustup-init -y --default-toolchain "${RUSTUP_DEFAULT_TOOLCHAIN}" + rm -f /tmp/rustup-init +} + +function apt_clean() { + apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false + rm -rf /var/lib/apt/lists/* /var/log/* +} + +if [[ "${INSTALLATION_TYPE}" == "RUNTIME" ]]; then + get_runtime_apt_deps + install_debian_runtime_dependencies + install_docker_cli + apt_clean +else + get_dev_apt_deps + install_debian_dev_dependencies + install_python + install_additional_dev_dependencies + install_rustup + if [[ "${INSTALLATION_TYPE}" == "CI" ]]; then + install_golang + fi + install_docker_cli + apt_clean +fi +EOF + +# The content below is automatically copied from scripts/docker/install_mysql.sh +COPY <<"EOF" /install_mysql.sh +#!/usr/bin/env bash +. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" + +set -euo pipefail + +common::get_colors +declare -a packages + +readonly MARIADB_LTS_VERSION="11.8" + +: "${INSTALL_MYSQL_CLIENT:?Should be true or false}" +: "${INSTALL_MYSQL_CLIENT_TYPE:-mariadb}" + +if [[ "${INSTALL_MYSQL_CLIENT}" != "true" && "${INSTALL_MYSQL_CLIENT}" != "false" ]]; then + echo + echo "${COLOR_RED}INSTALL_MYSQL_CLIENT must be either true or false${COLOR_RESET}" + echo + exit 1 +fi + +if [[ "${INSTALL_MYSQL_CLIENT_TYPE}" != "mysql" && "${INSTALL_MYSQL_CLIENT_TYPE}" != "mariadb" ]]; then + echo + echo "${COLOR_RED}INSTALL_MYSQL_CLIENT_TYPE must be either mysql or mariadb${COLOR_RESET}" + echo + exit 1 +fi + +if [[ "${INSTALL_MYSQL_CLIENT_TYPE}" == "mysql" ]]; then + echo + echo "${COLOR_RED}The 'mysql' client type is not supported any more. Use 'mariadb' instead.${COLOR_RESET}" + echo + echo "The MySQL drivers are wrongly packaged and released by Oracle with an expiration date on their GPG keys," + echo "which causes builds to fail after the expiration date. MariaDB client is protocol-compatible with MySQL client." + echo "" + echo "Every two years the MySQL packages fail and Oracle team is always surprised and struggling" + echo "with fixes and re-signing the packages which lasts few days" + echo "See https://bugs.mysql.com/bug.php?id=113432 for more details." + echo "As a community we are not able to support this broken packaging practice from Oracle" + echo "Feel free however to install MySQL drivers on your own as extension of the image." + echo + exit 1 +fi + +retry() { + local retries=3 + local count=0 + # adding delay of 10 seconds + local delay=10 + until "$@"; do + exit_code=$? + count=$((count + 1)) + if [[ $count -lt $retries ]]; then + echo "Command failed. Attempt $count/$retries. Retrying in ${delay}s..." + sleep $delay + else + echo "Command failed after $retries attempts." + return $exit_code + fi + done +} + +install_mariadb_client() { + # List of compatible package Oracle MySQL -> MariaDB: + # `mysql-client` -> `mariadb-client` or `mariadb-client-compat` (11+) + # `libmysqlclientXX` (where XX is a number) -> `libmariadb3-compat` + # `libmysqlclient-dev` -> `libmariadb-dev-compat` + # + # Different naming against Debian repo which we used before + # that some of packages might contains `-compat` suffix, Debian repo -> MariaDB repo: + # `libmariadb-dev` -> `libmariadb-dev-compat` + # `mariadb-client-core` -> `mariadb-client` or `mariadb-client-compat` (11+) + if [[ "${1}" == "dev" ]]; then + packages=("libmariadb-dev-compat" "mariadb-client") + elif [[ "${1}" == "prod" ]]; then + packages=("libmariadb3-compat" "mariadb-client") + else + echo + echo "${COLOR_RED}Specify either prod or dev${COLOR_RESET}" + echo + exit 1 + fi + + common::import_trusted_gpg "0xF1656F24C74CD1D8" "mariadb" + + echo + echo "${COLOR_BLUE}Installing MariaDB client version ${MARIADB_LTS_VERSION}: ${1}${COLOR_RESET}" + echo "${COLOR_YELLOW}MariaDB client protocol-compatible with MySQL client.${COLOR_RESET}" + echo + + echo "deb [arch=amd64,arm64] https://archive.mariadb.org/mariadb-${MARIADB_LTS_VERSION}/repo/debian/ $(lsb_release -cs) main" > \ + /etc/apt/sources.list.d/mariadb.list + # Make sure that dependencies from MariaDB repo are preferred over Debian dependencies + printf "Package: *\nPin: release o=MariaDB\nPin-Priority: 999\n" > /etc/apt/preferences.d/mariadb + retry apt-get update + retry apt-get install --no-install-recommends -y "${packages[@]}" + apt-get autoremove -yqq --purge + apt-get clean && rm -rf /var/lib/apt/lists/* +} + +if [[ ${INSTALL_MYSQL_CLIENT:="true"} == "true" ]]; then + install_mariadb_client "${@}" +fi +EOF + +# The content below is automatically copied from scripts/docker/install_mssql.sh +COPY <<"EOF" /install_mssql.sh +#!/usr/bin/env bash +. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" + +set -euo pipefail + +common::get_colors +declare -a packages + +: "${INSTALL_MSSQL_CLIENT:?Should be true or false}" + + +function install_mssql_client() { + # Install MsSQL client from Microsoft repositories + if [[ ${INSTALL_MSSQL_CLIENT:="true"} != "true" ]]; then + echo + echo "${COLOR_BLUE}Skip installing mssql client${COLOR_RESET}" + echo + return + fi + return #TODO here + packages=("msodbcsql18") + + common::import_trusted_gpg "EB3E94ADBE1229CF" "microsoft" + + echo + echo "${COLOR_BLUE}Installing mssql client${COLOR_RESET}" + echo + + echo "deb [arch=amd64,arm64] https://packages.microsoft.com/debian/$(lsb_release -rs)/prod $(lsb_release -cs) main" > \ + /etc/apt/sources.list.d/mssql-release.list && + mkdir -p /opt/microsoft/msodbcsql18 && + touch /opt/microsoft/msodbcsql18/ACCEPT_EULA && + apt-get update -yqq && + apt-get upgrade -yqq && + apt-get -yqq install --no-install-recommends "${packages[@]}" && + apt-get autoremove -yqq --purge && + apt-get clean && + rm -rf /var/lib/apt/lists/* +} + +install_mssql_client "${@}" +EOF + +# The content below is automatically copied from scripts/docker/install_postgres.sh +COPY <<"EOF" /install_postgres.sh +#!/usr/bin/env bash +. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" +set -euo pipefail + +common::get_colors +declare -a packages + +: "${INSTALL_POSTGRES_CLIENT:?Should be true or false}" + +install_postgres_client() { + echo + echo "${COLOR_BLUE}Installing postgres client${COLOR_RESET}" + echo + + if [[ "${1}" == "dev" ]]; then + packages=("libpq-dev" "postgresql-client") + elif [[ "${1}" == "prod" ]]; then + packages=("postgresql-client") + else + echo + echo "Specify either prod or dev" + echo + exit 1 + fi + + common::import_trusted_gpg "7FCC7D46ACCC4CF8" "postgres" + + echo "deb [arch=amd64,arm64] https://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > \ + /etc/apt/sources.list.d/pgdg.list + apt-get update + apt-get install --no-install-recommends -y "${packages[@]}" + apt-get autoremove -yqq --purge + apt-get clean && rm -rf /var/lib/apt/lists/* +} + +if [[ ${INSTALL_POSTGRES_CLIENT:="true"} == "true" ]]; then + install_postgres_client "${@}" +fi +EOF + +# The content below is automatically copied from scripts/docker/install_packaging_tools.sh +COPY <<"EOF" /install_packaging_tools.sh +#!/usr/bin/env bash +. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" + +common::get_colors +common::get_packaging_tool +common::show_packaging_tool_version_and_location +common::install_packaging_tools +EOF + +# The content below is automatically copied from scripts/docker/common.sh +COPY <<"EOF" /common.sh +#!/usr/bin/env bash +set -euo pipefail + +function common::get_colors() { + COLOR_BLUE=$'\e[34m' + COLOR_GREEN=$'\e[32m' + COLOR_RED=$'\e[31m' + COLOR_RESET=$'\e[0m' + COLOR_YELLOW=$'\e[33m' + export COLOR_BLUE + export COLOR_GREEN + export COLOR_RED + export COLOR_RESET + export COLOR_YELLOW +} + +function common::get_packaging_tool() { + : "${AIRFLOW_USE_UV:?Should be set}" + + ## IMPORTANT: IF YOU MODIFY THIS FUNCTION YOU SHOULD ALSO MODIFY CORRESPONDING FUNCTION IN + ## `scripts/in_container/_in_container_utils.sh` + if [[ ${AIRFLOW_USE_UV} == "true" ]]; then + echo + echo "${COLOR_BLUE}Using 'uv' to install Airflow${COLOR_RESET}" + echo + export PACKAGING_TOOL="uv" + export PACKAGING_TOOL_CMD="uv pip" + # --no-binary is needed in order to avoid libxml and xmlsec using different version of libxml2 + # (binary lxml embeds its own libxml2, while xmlsec uses system one). + # See https://bugs.launchpad.net/lxml/+bug/2110068 + if [[ ${AIRFLOW_INSTALLATION_METHOD=} == "." && -f "./pyproject.toml" ]]; then + # for uv only install dev group when we install from sources + export EXTRA_INSTALL_FLAGS="--group=dev --no-binary lxml --no-binary xmlsec" + else + export EXTRA_INSTALL_FLAGS="--no-binary lxml --no-binary xmlsec" + fi + export EXTRA_UNINSTALL_FLAGS="" + export UPGRADE_TO_HIGHEST_RESOLUTION="--upgrade --resolution highest" + export UPGRADE_IF_NEEDED="--upgrade" + UV_CONCURRENT_DOWNLOADS=$(nproc --all) + export UV_CONCURRENT_DOWNLOADS + if [[ ${INCLUDE_PRE_RELEASE=} == "true" ]]; then + EXTRA_INSTALL_FLAGS="${EXTRA_INSTALL_FLAGS} --prerelease if-necessary" + fi + else + echo + echo "${COLOR_BLUE}Using 'pip' to install Airflow${COLOR_RESET}" + echo + export PACKAGING_TOOL="pip" + export PACKAGING_TOOL_CMD="pip" + # --no-binary is needed in order to avoid libxml and xmlsec using different version of libxml2 + # (binary lxml embeds its own libxml2, while xmlsec uses system one). + # See https://bugs.launchpad.net/lxml/+bug/2110068 + export EXTRA_INSTALL_FLAGS="--root-user-action ignore --no-binary lxml,xmlsec" + export EXTRA_UNINSTALL_FLAGS="--yes" + export UPGRADE_TO_HIGHEST_RESOLUTION="--upgrade --upgrade-strategy eager" + export UPGRADE_IF_NEEDED="--upgrade --upgrade-strategy only-if-needed" + if [[ ${INCLUDE_PRE_RELEASE=} == "true" ]]; then + EXTRA_INSTALL_FLAGS="${EXTRA_INSTALL_FLAGS} --pre" + fi + fi +} + +function common::get_airflow_version_specification() { + if [[ -z ${AIRFLOW_VERSION_SPECIFICATION=} + && -n ${AIRFLOW_VERSION} + && ${AIRFLOW_INSTALLATION_METHOD} != "." ]]; then + AIRFLOW_VERSION_SPECIFICATION="==${AIRFLOW_VERSION}" + fi +} + +function common::get_constraints_location() { + # When installing from sources without upgrade, generate constraints from uv.lock + if [[ ${AIRFLOW_INSTALLATION_METHOD=} == "." && -z "${UPGRADE_RANDOM_INDICATOR_STRING=}" ]]; then + echo + echo "${COLOR_BLUE}Installing from sources with uv.lock - generating constraints from uv.lock${COLOR_RESET}" + echo + uv export --frozen --no-hashes --no-emit-project --no-editable --no-header \ + --no-annotate > "${HOME}/constraints.txt" 2>/dev/null || true + return + fi + + # auto-detect Airflow-constraint reference and location + if [[ -z "${AIRFLOW_CONSTRAINTS_REFERENCE=}" ]]; then + if [[ ${AIRFLOW_VERSION} =~ v?2.* || ${AIRFLOW_VERSION} =~ v?3.* ]]; then + AIRFLOW_CONSTRAINTS_REFERENCE=constraints-${AIRFLOW_VERSION} + else + AIRFLOW_CONSTRAINTS_REFERENCE=${DEFAULT_CONSTRAINTS_BRANCH} + fi + fi + + if [[ -z ${AIRFLOW_CONSTRAINTS_LOCATION=} ]]; then + local constraints_base="https://raw.githubusercontent.com/${CONSTRAINTS_GITHUB_REPOSITORY}/${AIRFLOW_CONSTRAINTS_REFERENCE}" + local python_version + python_version=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') + AIRFLOW_CONSTRAINTS_LOCATION="${constraints_base}/${AIRFLOW_CONSTRAINTS_MODE}-${python_version}.txt" + fi + + if [[ ${AIRFLOW_CONSTRAINTS_LOCATION} =~ http.* ]]; then + echo + echo "${COLOR_BLUE}Downloading constraints from ${AIRFLOW_CONSTRAINTS_LOCATION} to ${HOME}/constraints.txt ${COLOR_RESET}" + echo + if ! curl -sSf -o "${HOME}/constraints.txt" "${AIRFLOW_CONSTRAINTS_LOCATION}"; then + echo + echo "${COLOR_YELLOW}Constraints file not found at ${AIRFLOW_CONSTRAINTS_LOCATION} (new Python version being bootstrapped?).${COLOR_RESET}" + echo "${COLOR_YELLOW}Falling back to no-constraints installation.${COLOR_RESET}" + echo + AIRFLOW_CONSTRAINTS_LOCATION="" + # Create an empty constraints file so --constraint flag still works + touch "${HOME}/constraints.txt" + fi + else + echo + echo "${COLOR_BLUE}Copying constraints from ${AIRFLOW_CONSTRAINTS_LOCATION} to ${HOME}/constraints.txt ${COLOR_RESET}" + echo + cp "${AIRFLOW_CONSTRAINTS_LOCATION}" "${HOME}/constraints.txt" + fi +} + +function common::show_packaging_tool_version_and_location() { + echo "PATH=${PATH}" + echo "Installed pip: $(pip --version): $(which pip)" + if [[ ${PACKAGING_TOOL} == "pip" ]]; then + echo "${COLOR_BLUE}Using 'pip' to install Airflow${COLOR_RESET}" + else + echo "${COLOR_BLUE}Using 'uv' to install Airflow${COLOR_RESET}" + echo "Installed uv: $(uv --version 2>/dev/null || echo "Not installed yet"): $(which uv 2>/dev/null)" + fi +} + +function common::install_packaging_tools() { + : "${AIRFLOW_USE_UV:?Should be set}" + if [[ "${VIRTUAL_ENV=}" != "" ]]; then + echo + echo "${COLOR_BLUE}Checking packaging tools in venv: ${VIRTUAL_ENV}${COLOR_RESET}" + echo + else + echo + echo "${COLOR_BLUE}Checking packaging tools for system Python installation: $(which python)${COLOR_RESET}" + echo + fi + if [[ ${AIRFLOW_PIP_VERSION=} == "" ]]; then + echo + echo "${COLOR_BLUE}Installing latest pip version${COLOR_RESET}" + echo + pip install --root-user-action ignore --disable-pip-version-check --upgrade pip + elif [[ ! ${AIRFLOW_PIP_VERSION} =~ ^[0-9].* ]]; then + echo + echo "${COLOR_BLUE}Installing pip version from spec ${AIRFLOW_PIP_VERSION}${COLOR_RESET}" + echo + # shellcheck disable=SC2086 + pip install --root-user-action ignore --disable-pip-version-check "pip @ ${AIRFLOW_PIP_VERSION}" + else + local installed_pip_version + installed_pip_version=$(python -c 'from importlib.metadata import version; print(version("pip"))') + if [[ ${installed_pip_version} != "${AIRFLOW_PIP_VERSION}" ]]; then + echo + echo "${COLOR_BLUE}(Re)Installing pip version: ${AIRFLOW_PIP_VERSION}${COLOR_RESET}" + echo + pip install --root-user-action ignore --disable-pip-version-check "pip==${AIRFLOW_PIP_VERSION}" + fi + fi + if [[ ${AIRFLOW_UV_VERSION=} == "" ]]; then + echo + echo "${COLOR_BLUE}Installing latest uv version${COLOR_RESET}" + echo + pip install --root-user-action ignore --disable-pip-version-check --upgrade uv + elif [[ ! ${AIRFLOW_UV_VERSION} =~ ^[0-9].* ]]; then + echo + echo "${COLOR_BLUE}Installing uv version from spec ${AIRFLOW_UV_VERSION}${COLOR_RESET}" + echo + # shellcheck disable=SC2086 + pip install --root-user-action ignore --disable-pip-version-check "uv @ ${AIRFLOW_UV_VERSION}" + else + local installed_uv_version + installed_uv_version=$(python -c 'from importlib.metadata import version; print(version("uv"))' 2>/dev/null || echo "Not installed yet") + if [[ ${installed_uv_version} != "${AIRFLOW_UV_VERSION}" ]]; then + echo + echo "${COLOR_BLUE}(Re)Installing uv version: ${AIRFLOW_UV_VERSION}${COLOR_RESET}" + echo + # shellcheck disable=SC2086 + pip install --root-user-action ignore --disable-pip-version-check "uv==${AIRFLOW_UV_VERSION}" + fi + fi + if [[ ${AIRFLOW_PREK_VERSION=} == "" ]]; then + echo + echo "${COLOR_BLUE}Installing latest prek, uv${COLOR_RESET}" + echo + uv tool install prek --with uv + # make sure that the venv/user in .local exists + mkdir -p "${HOME}/.local/bin" + else + echo + echo "${COLOR_BLUE}Installing predefined versions of prek, uv:${COLOR_RESET}" + echo "${COLOR_BLUE}prek(${AIRFLOW_PREK_VERSION}) uv(${AIRFLOW_UV_VERSION})${COLOR_RESET}" + echo + uv tool install "prek==${AIRFLOW_PREK_VERSION}" --with "uv==${AIRFLOW_UV_VERSION}" + # make sure that the venv/user in .local exists + mkdir -p "${HOME}/.local/bin" + fi +} + +function common::import_trusted_gpg() { + common::get_colors + + local key=${1:?${COLOR_RED}First argument expects OpenPGP Key ID${COLOR_RESET}} + local name=${2:?${COLOR_RED}Second argument expected trust storage name${COLOR_RESET}} + local key_file="/scripts/docker/keys/${name}.asc" + + echo "${COLOR_BLUE}Installing GPG public key ${key} from ${key_file}${COLOR_RESET}" + gpg --dearmor < "${key_file}" > "/etc/apt/trusted.gpg.d/${name}.gpg" +} +EOF + +# The content below is automatically copied from scripts/docker/pip +COPY <<"EOF" /pip +#!/usr/bin/env bash +COLOR_RED=$'\e[31m' +COLOR_RESET=$'\e[0m' +COLOR_YELLOW=$'\e[33m' + +if [[ $(id -u) == "0" ]]; then + echo + echo "${COLOR_RED}You are running pip as root. Please use 'airflow' user to run pip!${COLOR_RESET}" + echo + echo "${COLOR_YELLOW}See: https://airflow.apache.org/docs/docker-stack/build.html#adding-new-pypi-packages-individually${COLOR_RESET}" + echo + exit 1 +fi +exec "${HOME}"/.local/bin/pip "${@}" +EOF + +# The content below is automatically copied from scripts/docker/install_from_docker_context_files.sh +COPY <<"EOF" /install_from_docker_context_files.sh + +. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" + + +function install_airflow_and_providers_from_docker_context_files(){ + local flags=() + if [[ ${INSTALL_MYSQL_CLIENT} != "true" ]]; then + AIRFLOW_EXTRAS=${AIRFLOW_EXTRAS/mysql,} + fi + if [[ ${INSTALL_POSTGRES_CLIENT} != "true" ]]; then + AIRFLOW_EXTRAS=${AIRFLOW_EXTRAS/postgres,} + fi + + if [[ ! -d /docker-context-files ]]; then + echo + echo "${COLOR_RED}You must provide a folder via --build-arg DOCKER_CONTEXT_FILES= and you missed it!${COLOR_RESET}" + echo + exit 1 + fi + + # This is needed to get distribution names for local context distributions + if [[ -f "${HOME}/constraints.txt" ]]; then + ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${ADDITIONAL_PIP_INSTALL_FLAGS} --constraint ${HOME}/constraints.txt packaging + else + ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${ADDITIONAL_PIP_INSTALL_FLAGS} packaging + fi + + if [[ -n ${AIRFLOW_EXTRAS=} ]]; then + AIRFLOW_EXTRAS_TO_INSTALL="[${AIRFLOW_EXTRAS}]" + else + AIRFLOW_EXTRAS_TO_INSTALL="" + fi + + # Find apache-airflow distribution in docker-context files + readarray -t install_airflow_distribution < <(EXTRAS="${AIRFLOW_EXTRAS_TO_INSTALL}" \ + python /scripts/docker/get_distribution_specs.py /docker-context-files/apache?airflow?[0-9]*.{whl,tar.gz} 2>/dev/null || true) + echo + echo "${COLOR_BLUE}Found apache-airflow distributions in docker-context-files folder: ${install_airflow_distribution[*]}${COLOR_RESET}" + echo + + if [[ -z "${install_airflow_distribution[*]}" && ${AIRFLOW_VERSION=} != "" ]]; then + # When we install only provider distributions from docker-context files, we need to still + # install airflow from PyPI when AIRFLOW_VERSION is set. This handles the case where + # pre-release dockerhub image of airflow is built, but we want to install some providers from + # docker-context files + install_airflow_distribution=("apache-airflow[${AIRFLOW_EXTRAS}]==${AIRFLOW_VERSION}") + fi + + # Find apache-airflow-core distribution in docker-context files + readarray -t install_airflow_core_distribution < <(EXTRAS="" \ + python /scripts/docker/get_distribution_specs.py /docker-context-files/apache?airflow?core?[0-9]*.{whl,tar.gz} 2>/dev/null || true) + echo + echo "${COLOR_BLUE}Found apache-airflow-core distributions in docker-context-files folder: ${install_airflow_core_distribution[*]}${COLOR_RESET}" + echo + + if [[ -z "${install_airflow_core_distribution[*]}" && ${AIRFLOW_VERSION=} != "" ]]; then + # When we install only provider distributions from docker-context files, we need to still + # install airflow from PyPI when AIRFLOW_VERSION is set. This handles the case where + # pre-release dockerhub image of airflow is built, but we want to install some providers from + # docker-context files + install_airflow_core_distribution=("apache-airflow-core==${AIRFLOW_VERSION}") + fi + + # Find Provider/TaskSDK/CTL distributions in docker-context files. + # NOTE: the ctl wheel is named ``apache_airflow_ctl-*.whl`` (distribution + # ``apache-airflow-ctl``), not ``apache_airflow_airflowctl-*.whl`` — the + # glob must say ``ctl``, not ``airflowctl``. + readarray -t airflow_distributions< <(python /scripts/docker/get_distribution_specs.py /docker-context-files/apache?airflow?{providers,task?sdk,ctl}*.{whl,tar.gz} 2>/dev/null || true) + echo + echo "${COLOR_BLUE}Found provider distributions in docker-context-files folder: ${airflow_distributions[*]}${COLOR_RESET}" + echo + + if [[ ${USE_CONSTRAINTS_FOR_CONTEXT_DISTRIBUTIONS=} == "true" ]]; then + local python_version + python_version=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') + local local_constraints_file=/docker-context-files/constraints-"${python_version}"/${AIRFLOW_CONSTRAINTS_MODE}-"${python_version}".txt + + if [[ -f "${local_constraints_file}" ]]; then + echo + echo "${COLOR_BLUE}Installing docker-context-files distributions with constraints found in ${local_constraints_file}${COLOR_RESET}" + echo + # force reinstall all airflow + provider distributions with constraints found in + flags=(--upgrade --constraint "${local_constraints_file}") + echo + echo "${COLOR_BLUE}Copying ${local_constraints_file} to ${HOME}/constraints.txt${COLOR_RESET}" + echo + cp "${local_constraints_file}" "${HOME}/constraints.txt" + else + echo + echo "${COLOR_BLUE}Installing docker-context-files distributions with constraints from GitHub${COLOR_RESET}" + echo + flags=(--constraint "${HOME}/constraints.txt") + fi + else + echo + echo "${COLOR_BLUE}Installing docker-context-files distributions without constraints${COLOR_RESET}" + echo + flags=() + fi + + set -x + if ! ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} \ + ${ADDITIONAL_PIP_INSTALL_FLAGS} \ + "${flags[@]}" \ + "${install_airflow_distribution[@]}" "${install_airflow_core_distribution[@]}" "${airflow_distributions[@]}"; then + set +x + if [[ ${AIRFLOW_FALLBACK_NO_CONSTRAINTS_INSTALLATION} != "true" ]]; then + echo + echo "${COLOR_RED}Failing because constraints installation failed and fallback is disabled.${COLOR_RESET}" + echo + exit 1 + fi + echo + echo "${COLOR_YELLOW}Likely there are new dependencies conflicting with constraints.${COLOR_RESET}" + echo + echo "${COLOR_BLUE}Falling back to no-constraints installation.${COLOR_RESET}" + echo + set -x + ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} \ + ${ADDITIONAL_PIP_INSTALL_FLAGS} \ + "${install_airflow_distribution[@]}" "${install_airflow_core_distribution[@]}" \ + "${airflow_distributions[@]}" + fi + set +x + common::install_packaging_tools + # We use pip check here to make sure that whatever `uv` installs, is also "correct" according to `pip` + pip check +} + +function install_all_other_distributions_from_docker_context_files() { + echo + echo "${COLOR_BLUE}Force re-installing all other distributions from local files without dependencies${COLOR_RESET}" + echo + local reinstalling_other_distributions + # shellcheck disable=SC2010 + reinstalling_other_distributions=$(ls /docker-context-files/*.{whl,tar.gz} 2>/dev/null | \ + grep -v apache_airflow | grep -v apache-airflow || true) + if [[ -n "${reinstalling_other_distributions}" ]]; then + set -x + ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${ADDITIONAL_PIP_INSTALL_FLAGS} \ + --force-reinstall --no-deps --no-index ${reinstalling_other_distributions} + common::install_packaging_tools + set +x + fi +} + +common::get_colors +common::get_packaging_tool +common::get_airflow_version_specification +common::get_constraints_location +common::show_packaging_tool_version_and_location + +install_airflow_and_providers_from_docker_context_files + +install_all_other_distributions_from_docker_context_files +EOF + +# The content below is automatically copied from scripts/docker/get_distribution_specs.py +COPY <<"EOF" /get_distribution_specs.py +#!/usr/bin/env python +from __future__ import annotations + +import os +import sys +import zipfile +from email.parser import HeaderParser +from pathlib import Path + +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.utils import ( + InvalidSdistFilename, + InvalidWheelFilename, + parse_sdist_filename, + parse_wheel_filename, +) + +_CURRENT_PYTHON = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + + +def _compatible_with_current_python(wheel_path: str) -> bool: + """Return False if the wheel's Requires-Python excludes the running interpreter.""" + try: + with zipfile.ZipFile(wheel_path) as zf: + for name in zf.namelist(): + if name.endswith(".dist-info/METADATA"): + requires = HeaderParser().parsestr(zf.read(name).decode("utf-8")).get("Requires-Python") + if requires: + return _CURRENT_PYTHON in SpecifierSet(requires) + return True + except (zipfile.BadZipFile, InvalidSpecifier, KeyError) as exc: + print(f"Warning: could not check Requires-Python for {wheel_path}: {exc}", file=sys.stderr) + return True + + +def print_package_specs(extras: str = "") -> None: + for package_path in sys.argv[1:]: + try: + package, _, _, _ = parse_wheel_filename(Path(package_path).name) + except InvalidWheelFilename: + try: + package, _ = parse_sdist_filename(Path(package_path).name) + except InvalidSdistFilename: + print(f"Could not parse package name from {package_path}", file=sys.stderr) + continue + if package_path.endswith(".whl") and not _compatible_with_current_python(package_path): + print( + f"Skipping {package} (Requires-Python not satisfied by {_CURRENT_PYTHON})", + file=sys.stderr, + ) + continue + print(f"{package}{extras} @ file://{package_path}") + + +if __name__ == "__main__": + print_package_specs(extras=os.environ.get("EXTRAS", "")) +EOF + + +# The content below is automatically copied from scripts/docker/install_airflow_when_building_images.sh +COPY <<"EOF" /install_airflow_when_building_images.sh +#!/usr/bin/env bash + +. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" + +function install_from_sources() { + local extra_sync_flags + extra_sync_flags="" + if [[ ${VIRTUAL_ENV=} != "" ]]; then + extra_sync_flags="--active" + fi + if [[ "${UPGRADE_RANDOM_INDICATOR_STRING=}" != "" ]]; then + if [[ ${PACKAGING_TOOL_CMD} == "pip" ]]; then + set +x + echo + echo "${COLOR_RED}We only support uv not pip installation for upgrading dependencies!.${COLOR_RESET}" + echo + exit 1 + fi + set +x + echo + echo "${COLOR_BLUE}Attempting to upgrade all packages to highest versions.${COLOR_RESET}" + echo + # --no-binary is needed in order to avoid libxml and xmlsec using different version of libxml2 + # (binary lxml embeds its own libxml2, while xmlsec uses system one). + # See https://bugs.launchpad.net/lxml/+bug/2110068 + set -x + uv sync --all-packages --resolution highest --group ci-image \ + ${extra_sync_flags} --no-binary-package lxml --no-binary-package xmlsec \ + --no-python-downloads --no-managed-python + else + set +x + echo + echo "${COLOR_BLUE}Installing all packages from uv.lock (frozen).${COLOR_RESET}" + echo + # Use uv sync --frozen to install exactly what is pinned in uv.lock without re-resolving. + # --no-binary-package is needed in order to avoid libxml and xmlsec using different version of + # libxml2 (binary lxml embeds its own libxml2, while xmlsec uses system one). + # See https://bugs.launchpad.net/lxml/+bug/2110068 + set -x + if ! uv sync --all-packages --frozen --group ci-image \ + ${extra_sync_flags} --no-binary-package lxml --no-binary-package xmlsec \ + --no-python-downloads --no-managed-python; then + set +x + if [[ ${AIRFLOW_FALLBACK_NO_CONSTRAINTS_INSTALLATION} != "true" ]]; then + echo + echo "${COLOR_RED}Failing because frozen uv.lock installation failed and fallback is disabled.${COLOR_RESET}" + echo + exit 1 + fi + echo + echo "${COLOR_YELLOW}Likely pyproject.toml has new dependencies not reflected in uv.lock.${COLOR_RESET}" + echo + echo "${COLOR_BLUE}Falling back to re-resolving dependencies (uv sync without --frozen).${COLOR_RESET}" + echo + set -x + uv sync --all-packages --group ci-image \ + ${extra_sync_flags} --no-binary-package lxml --no-binary-package xmlsec \ + --no-python-downloads --no-managed-python + set +x + fi + fi +} + +function install_from_external_spec() { + local installation_command_flags + if [[ ${AIRFLOW_INSTALLATION_METHOD} == "apache-airflow" ]]; then + installation_command_flags="apache-airflow[${AIRFLOW_EXTRAS}]${AIRFLOW_VERSION_SPECIFICATION}" + else + echo + echo "${COLOR_RED}The '${AIRFLOW_INSTALLATION_METHOD}' installation method is not supported${COLOR_RESET}" + echo + echo "${COLOR_YELLOW}Supported methods are ('.', 'apache-airflow')${COLOR_RESET}" + echo + exit 1 + fi + if [[ "${UPGRADE_RANDOM_INDICATOR_STRING=}" != "" ]]; then + echo + echo "${COLOR_BLUE}Remove airflow and all provider distributions installed before potentially${COLOR_RESET}" + echo + set -x + ${PACKAGING_TOOL_CMD} freeze | grep apache-airflow | xargs ${PACKAGING_TOOL_CMD} uninstall ${EXTRA_UNINSTALL_FLAGS} 2>/dev/null || true + set +x + echo + echo "${COLOR_BLUE}Installing all packages with highest resolutions. Installation method: ${AIRFLOW_INSTALLATION_METHOD}${COLOR_RESET}" + echo + set -x + ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${UPGRADE_TO_HIGHEST_RESOLUTION} ${ADDITIONAL_PIP_INSTALL_FLAGS} ${installation_command_flags} + set +x + else + echo + echo "${COLOR_BLUE}Installing all packages with constraints. Installation method: ${AIRFLOW_INSTALLATION_METHOD}${COLOR_RESET}" + echo + set -x + if ! ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${ADDITIONAL_PIP_INSTALL_FLAGS} ${installation_command_flags} --constraint "${HOME}/constraints.txt"; then + set +x + if [[ ${AIRFLOW_FALLBACK_NO_CONSTRAINTS_INSTALLATION} != "true" ]]; then + echo + echo "${COLOR_RED}Failing because constraints installation failed and fallback is disabled.${COLOR_RESET}" + echo + exit 1 + fi + echo + echo "${COLOR_YELLOW}Likely pyproject.toml has new dependencies conflicting with constraints.${COLOR_RESET}" + echo + echo "${COLOR_BLUE}Falling back to no-constraints installation.${COLOR_RESET}" + echo + set -x + ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${UPGRADE_IF_NEEDED} ${ADDITIONAL_PIP_INSTALL_FLAGS} ${installation_command_flags} + set +x + fi + fi +} + + +function install_airflow_when_building_images() { + # Remove mysql from extras if client is not going to be installed + if [[ ${INSTALL_MYSQL_CLIENT} != "true" ]]; then + AIRFLOW_EXTRAS=${AIRFLOW_EXTRAS/mysql,} + echo "${COLOR_YELLOW}MYSQL client installation is disabled. Extra 'mysql' installations were therefore omitted.${COLOR_RESET}" + fi + # Remove postgres from extras if client is not going to be installed + if [[ ${INSTALL_POSTGRES_CLIENT} != "true" ]]; then + AIRFLOW_EXTRAS=${AIRFLOW_EXTRAS/postgres,} + echo "${COLOR_YELLOW}Postgres client installation is disabled. Extra 'postgres' installations were therefore omitted.${COLOR_RESET}" + fi + # Determine the installation_command_flags based on AIRFLOW_INSTALLATION_METHOD method + if [[ ${AIRFLOW_INSTALLATION_METHOD} == "." ]]; then + install_from_sources + else + install_from_external_spec + fi + set +x + common::install_packaging_tools + echo + echo "${COLOR_BLUE}Running 'pip check'${COLOR_RESET}" + echo + # We use pip check here to make sure that whatever `uv` installs, is also "correct" according to `pip` + pip check +} + +common::get_colors +common::get_packaging_tool +common::get_airflow_version_specification +common::get_constraints_location +common::show_packaging_tool_version_and_location + +install_airflow_when_building_images +EOF + +# The content below is automatically copied from scripts/docker/install_additional_dependencies.sh +COPY <<"EOF" /install_additional_dependencies.sh +#!/usr/bin/env bash +set -euo pipefail + +: "${ADDITIONAL_PYTHON_DEPS:?Should be set}" + +. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" + +function install_additional_dependencies() { + if [[ "${UPGRADE_RANDOM_INDICATOR_STRING=}" != "" ]]; then + echo + echo "${COLOR_BLUE}Installing additional dependencies while upgrading to newer dependencies${COLOR_RESET}" + echo + set -x + ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${UPGRADE_TO_HIGHEST_RESOLUTION} \ + ${ADDITIONAL_PIP_INSTALL_FLAGS} \ + ${ADDITIONAL_PYTHON_DEPS} + set +x + common::install_packaging_tools + echo + echo "${COLOR_BLUE}Running 'pip check'${COLOR_RESET}" + echo + # We use pip check here to make sure that whatever `uv` installs, is also "correct" according to `pip` + pip check + else + echo + echo "${COLOR_BLUE}Installing additional dependencies upgrading only if needed${COLOR_RESET}" + echo + set -x + ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${UPGRADE_IF_NEEDED} \ + ${ADDITIONAL_PIP_INSTALL_FLAGS} \ + ${ADDITIONAL_PYTHON_DEPS} + set +x + common::install_packaging_tools + echo + echo "${COLOR_BLUE}Running 'pip check'${COLOR_RESET}" + echo + # We use pip check here to make sure that whatever `uv` installs, is also "correct" according to `pip` + pip check + fi +} + +common::get_colors +common::get_packaging_tool +common::get_airflow_version_specification +common::get_constraints_location +common::show_packaging_tool_version_and_location + +install_additional_dependencies +EOF + +# The content below is automatically copied from scripts/docker/create_prod_venv.sh +COPY <<"EOF" /create_prod_venv.sh +#!/usr/bin/env bash +. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" + +function create_prod_venv() { + echo + echo "${COLOR_BLUE}Removing ${HOME}/.local and re-creating it as virtual environment.${COLOR_RESET}" + rm -rf ~/.local + python -m venv ~/.local + echo "${COLOR_BLUE}The ${HOME}/.local virtualenv created.${COLOR_RESET}" +} + +common::get_colors +common::get_packaging_tool +common::show_packaging_tool_version_and_location +create_prod_venv +common::install_packaging_tools +EOF + + +# The content below is automatically copied from scripts/docker/entrypoint_prod.sh +COPY <<"EOF" /entrypoint_prod.sh +#!/usr/bin/env bash +AIRFLOW_COMMAND="${1:-}" +AIRFLOW_COMMAND_TO_RUN="${AIRFLOW_COMMAND}" +if [[ "${AIRFLOW_COMMAND}" == "airflow" ]]; then + AIRFLOW_COMMAND_TO_RUN="${2:-}" +elif [[ "${AIRFLOW_COMMAND}" =~ ^(bash|sh)$ ]] \ + && [[ "${2:-}" == "-c" ]] \ + && [[ "${3:-}" =~ (^|[[:space:]])(exec[[:space:]]+)?airflow[[:space:]]+(scheduler|dag-processor|triggerer|api-server)([[:space:]]|$) ]]; then + AIRFLOW_COMMAND_TO_RUN="${BASH_REMATCH[3]}" +fi + +set -euo pipefail + +LD_PRELOAD="/usr/lib/$(uname -m)-linux-gnu/libstdc++.so.6" +export LD_PRELOAD + +function run_check_with_retries { + local cmd + cmd="${1}" + local countdown + countdown="${CONNECTION_CHECK_MAX_COUNT}" + + while true + do + set +e + local last_check_result + local res + last_check_result=$(eval "${cmd} 2>&1") + res=$? + set -e + if [[ ${res} == 0 ]]; then + echo + break + else + echo -n "." + countdown=$((countdown-1)) + fi + if [[ ${countdown} == 0 ]]; then + echo + echo "ERROR! Maximum number of retries (${CONNECTION_CHECK_MAX_COUNT}) reached." + echo + echo "Last check result:" + echo "$ ${cmd}" + echo "${last_check_result}" + echo + exit 1 + else + sleep "${CONNECTION_CHECK_SLEEP_TIME}" + fi + done +} + +function run_nc() { + # Checks if it is possible to connect to the host using netcat. + # + # We want to avoid misleading messages and perform only forward lookup of the service IP address. + # Netcat when run without -n performs both forward and reverse lookup and fails if the reverse + # lookup name does not match the original name even if the host is reachable via IP. This happens + # randomly with docker-compose in GitHub Actions. + # Since we are not using reverse lookup elsewhere, we can perform forward lookup in python + # And use the IP in NC and add '-n' switch to disable any DNS use. + # Even if this message might be harmless, it might hide the real reason for the problem + # Which is the long time needed to start some services, seeing this message might be totally misleading + # when you try to analyse the problem, that's why it's best to avoid it, + local host="${1}" + local port="${2}" + local ip + ip=$(python -c "import socket; print(socket.gethostbyname('${host}'))") + nc -zvvn "${ip}" "${port}" +} + + +function wait_for_connection { + # Waits for Connection to the backend specified via URL passed as first parameter + # Detects backend type depending on the URL schema and assigns + # default port numbers if not specified in the URL. + # Then it loops until connection to the host/port specified can be established + # It tries `CONNECTION_CHECK_MAX_COUNT` times and sleeps `CONNECTION_CHECK_SLEEP_TIME` between checks + local connection_url + connection_url="${1}" + local detected_backend + detected_backend=$(python -c "from urllib.parse import urlsplit; import sys; print(urlsplit(sys.argv[1]).scheme)" "${connection_url}") + local detected_host + detected_host=$(python -c "from urllib.parse import urlsplit; import sys; print(urlsplit(sys.argv[1]).hostname or '')" "${connection_url}") + local detected_port + detected_port=$(python -c "from urllib.parse import urlsplit; import sys; print(urlsplit(sys.argv[1]).port or '')" "${connection_url}") + + echo BACKEND="${BACKEND:=${detected_backend}}" + readonly BACKEND + + if [[ -z "${detected_port=}" ]]; then + if [[ ${BACKEND} == "postgres"* ]]; then + detected_port=5432 + elif [[ ${BACKEND} == "mysql"* ]]; then + detected_port=3306 + elif [[ ${BACKEND} == "mssql"* ]]; then + detected_port=1433 + elif [[ ${BACKEND} == "redis"* ]]; then + detected_port=6379 + elif [[ ${BACKEND} == "amqp"* ]]; then + detected_port=5672 + fi + fi + + detected_host=${detected_host:="localhost"} + + # Allow the DB parameters to be overridden by environment variable + echo DB_HOST="${DB_HOST:=${detected_host}}" + readonly DB_HOST + + echo DB_PORT="${DB_PORT:=${detected_port}}" + readonly DB_PORT + if [[ -n "${DB_HOST=}" ]] && [[ -n "${DB_PORT=}" ]]; then + run_check_with_retries "run_nc ${DB_HOST@Q} ${DB_PORT@Q}" + else + >&2 echo "The connection details to the broker could not be determined. Connectivity checks were skipped." + fi +} + +function create_www_user() { + local local_password="" + # Warning: command environment variables (*_CMD) have priority over usual configuration variables + # for configuration parameters that require sensitive information. This is the case for the SQL database + # and the broker backend in this entrypoint script. + if [[ -n "${_AIRFLOW_WWW_USER_PASSWORD_CMD=}" ]]; then + local_password=$(eval "${_AIRFLOW_WWW_USER_PASSWORD_CMD}") + unset _AIRFLOW_WWW_USER_PASSWORD_CMD + elif [[ -n "${_AIRFLOW_WWW_USER_PASSWORD=}" ]]; then + local_password="${_AIRFLOW_WWW_USER_PASSWORD}" + unset _AIRFLOW_WWW_USER_PASSWORD + fi + if [[ -z ${local_password} ]]; then + echo + echo "ERROR! Airflow Admin password not set via _AIRFLOW_WWW_USER_PASSWORD or _AIRFLOW_WWW_USER_PASSWORD_CMD variables!" + echo + exit 1 + fi + + if airflow config get-value core auth_manager | grep -q "FabAuthManager"; then + airflow users create \ + --username "${_AIRFLOW_WWW_USER_USERNAME="admin"}" \ + --firstname "${_AIRFLOW_WWW_USER_FIRSTNAME="Airflow"}" \ + --lastname "${_AIRFLOW_WWW_USER_LASTNAME="Admin"}" \ + --email "${_AIRFLOW_WWW_USER_EMAIL="airflowadmin@example.com"}" \ + --role "${_AIRFLOW_WWW_USER_ROLE="Admin"}" \ + --password "${local_password}" || true + else + echo "Skipping user creation as auth manager different from Fab is used" + fi +} + +function create_system_user_if_missing() { + # This is needed in case of OpenShift-compatible container execution. In case of OpenShift random + # User id is used when starting the image, however group 0 is kept as the user group. Our production + # Image is OpenShift compatible, so all permissions on all folders are set so that 0 group can exercise + # the same privileges as the default "airflow" user, this code checks if the user is already + # present in /etc/passwd and will create the system user dynamically, including setting its + # HOME directory to the /home/airflow so that (for example) the ${HOME}/.local folder where airflow is + # Installed can be automatically added to PYTHONPATH + if ! whoami &> /dev/null; then + if [[ -w /etc/passwd ]]; then + echo "${USER_NAME:-default}:x:$(id -u):0:${USER_NAME:-default} user:${AIRFLOW_USER_HOME_DIR}:/sbin/nologin" \ + >> /etc/passwd + fi + export HOME="${AIRFLOW_USER_HOME_DIR}" + fi +} + +function set_pythonpath_for_root_user() { + # Airflow is installed as a local user application which means that if the container is running as root + # the application is not available. because Python then only load system-wide applications. + # Now also adds applications installed as local user "airflow". + if [[ $UID == "0" ]]; then + local python_major_minor + python_major_minor=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') + export PYTHONPATH="${AIRFLOW_USER_HOME_DIR}/.local/lib/python${python_major_minor}/site-packages:${PYTHONPATH:-}" + >&2 echo "The container is run as root user. For security, consider using a regular user account." + fi +} + +function wait_for_airflow_db() { + # Wait for the command to run successfully to validate the database connection. + run_check_with_retries "airflow db check" +} + +function migrate_db() { + # Runs airflow db migrate + airflow db migrate || true +} + +function wait_for_celery_broker() { + # Verifies connection to Celery Broker + local executor + executor="$(airflow config get-value core executor)" + if [[ "${executor}" == "CeleryExecutor" ]]; then + local connection_url + connection_url="$(airflow config get-value celery broker_url)" + wait_for_connection "${connection_url}" + fi +} + +function exec_to_bash_or_python_command_if_specified() { + # If one of the commands: 'bash', 'python' is used, either run appropriate + # command with exec + if [[ ${AIRFLOW_COMMAND} == "bash" ]]; then + shift + exec "/bin/bash" "${@}" + elif [[ ${AIRFLOW_COMMAND} == "python" ]]; then + shift + exec "python" "${@}" + fi +} + +function check_uid_gid() { + if [[ $(id -g) == "0" ]]; then + return + fi + if [[ $(id -u) == "50000" ]]; then + >&2 echo + >&2 echo "WARNING! You should run the image with GID (Group ID) set to 0" + >&2 echo " even if you use 'airflow' user (UID=50000)" + >&2 echo + >&2 echo " You started the image with UID=$(id -u) and GID=$(id -g)" + >&2 echo + >&2 echo " This is to make sure you can run the image with an arbitrary UID in the future." + >&2 echo + >&2 echo " See more about it in the Airflow's docker image documentation" + >&2 echo " https://airflow.apache.org/docs/docker-stack/entrypoint.html" + >&2 echo + # We still allow the image to run with `airflow` user. + return + else + >&2 echo + >&2 echo "ERROR! You should run the image with GID=0" + >&2 echo + >&2 echo " You started the image with UID=$(id -u) and GID=$(id -g)" + >&2 echo + >&2 echo "The image should always be run with GID (Group ID) set to 0 regardless of the UID used." + >&2 echo " This is to make sure you can run the image with an arbitrary UID." + >&2 echo + >&2 echo " See more about it in the Airflow's docker image documentation" + >&2 echo " https://airflow.apache.org/docs/docker-stack/entrypoint.html" + # This will not work so we fail hard + exit 1 + fi +} + +unset PIP_USER + +check_uid_gid + +umask 0002 + +CONNECTION_CHECK_MAX_COUNT=${CONNECTION_CHECK_MAX_COUNT:=20} +readonly CONNECTION_CHECK_MAX_COUNT + +CONNECTION_CHECK_SLEEP_TIME=${CONNECTION_CHECK_SLEEP_TIME:=3} +readonly CONNECTION_CHECK_SLEEP_TIME + +create_system_user_if_missing +set_pythonpath_for_root_user +if [[ "${CONNECTION_CHECK_MAX_COUNT}" -gt "0" ]] \ + && [[ ${AIRFLOW_COMMAND_TO_RUN} =~ ^(scheduler|dag-processor|triggerer|api-server)$ ]]; then + wait_for_airflow_db +fi + +if [[ -n "${_AIRFLOW_DB_UPGRADE=}" ]] || [[ -n "${_AIRFLOW_DB_MIGRATE=}" ]] ; then + migrate_db +fi + +if [[ -n "${_AIRFLOW_DB_UPGRADE=}" ]] ; then + >&2 echo "WARNING: Environment variable '_AIRFLOW_DB_UPGRADE' is deprecated please use '_AIRFLOW_DB_MIGRATE' instead" +fi + +if [[ -n "${_AIRFLOW_WWW_USER_CREATE=}" ]] ; then + create_www_user +fi + +if [[ -n "${_PIP_ADDITIONAL_REQUIREMENTS=}" ]] ; then + >&2 echo + >&2 echo "!!!!! Installing additional requirements: '${_PIP_ADDITIONAL_REQUIREMENTS}' !!!!!!!!!!!!" + >&2 echo + >&2 echo "WARNING: This is a development/test feature only. NEVER use it in production!" + >&2 echo " Instead, build a custom image as described in" + >&2 echo + >&2 echo " https://airflow.apache.org/docs/docker-stack/build.html" + >&2 echo + >&2 echo " Adding requirements at container startup is fragile and is done every time" + >&2 echo " the container starts, so it is only useful for testing and trying out" + >&2 echo " of adding dependencies." + >&2 echo + pip install --root-user-action ignore ${_PIP_ADDITIONAL_REQUIREMENTS} +fi + + +exec_to_bash_or_python_command_if_specified "${@}" + +if [[ ${AIRFLOW_COMMAND} == "airflow" ]]; then + AIRFLOW_COMMAND="${2:-}" + shift +fi + +if [[ ${AIRFLOW_COMMAND} =~ ^(scheduler|celery)$ ]] \ + && [[ "${CONNECTION_CHECK_MAX_COUNT}" -gt "0" ]]; then + wait_for_celery_broker +fi + +if [[ "$#" -eq 0 && "${_AIRFLOW_DB_MIGRATE}" == "true" ]]; then + echo "[INFO] No commands passed and _AIRFLOW_DB_MIGRATE=true. Exiting script with code 0." + exit 0 +fi + +exec "airflow" "${@}" +EOF + +# The content below is automatically copied from scripts/docker/clean-logs.sh +COPY <<"EOF" /clean-logs.sh +#!/usr/bin/env bash + + +set -euo pipefail + +readonly DIRECTORY="${AIRFLOW_HOME:-/usr/local/airflow}" +readonly RETENTION_DAYS="${AIRFLOW__LOG_RETENTION_DAYS:-15}" +readonly RETENTION_MINUTES="${AIRFLOW__LOG_RETENTION_MINUTES:-0}" +readonly FREQUENCY="${AIRFLOW__LOG_CLEANUP_FREQUENCY_MINUTES:-15}" +readonly MAX_PERCENT="${AIRFLOW__LOG_MAX_SIZE_PERCENT:-0}" + +trap "exit" INT TERM + +MAX_SIZE_BYTES="${AIRFLOW__LOG_MAX_SIZE_BYTES:-0}" +if [[ "$MAX_SIZE_BYTES" -eq 0 && "$MAX_PERCENT" -gt 0 ]]; then + total_space=$(df -k "${DIRECTORY}"/logs 2>/dev/null | tail -1 | awk '{print $2}' || echo "0") + MAX_SIZE_BYTES=$(( total_space * 1024 * MAX_PERCENT / 100 )) + echo "Computed MAX_SIZE_BYTES from ${MAX_PERCENT}% of disk: ${MAX_SIZE_BYTES} bytes" +fi + +readonly MAX_SIZE_BYTES + +readonly EVERY=$((FREQUENCY*60)) + +echo "Cleaning logs every $EVERY seconds" +if [[ "$MAX_SIZE_BYTES" -gt 0 ]]; then + echo "Max log size limit: $MAX_SIZE_BYTES bytes" +fi + +retention_days="${RETENTION_DAYS}" + +while true; do + total_retention_minutes=$(( (retention_days * 1440) + RETENTION_MINUTES )) + echo "Trimming airflow logs older than ${total_retention_minutes} minutes." + + find "${DIRECTORY}"/logs \ + -type d -name 'lost+found' -prune -o \ + -type f -mmin +"${total_retention_minutes}" -name '*.log' -print0 | \ + xargs -0 rm -f || true + + if [[ "$MAX_SIZE_BYTES" -gt 0 && "$retention_days" -ge 0 ]]; then + current_size=$(df -k "${DIRECTORY}"/logs 2>/dev/null | tail -1 | awk '{print $3}' || echo "0") + current_size=$(( current_size * 1024 )) + + if [[ "$current_size" -gt "$MAX_SIZE_BYTES" ]]; then + retention_days=$((retention_days - 1)) + echo "Size ($current_size bytes) exceeds limit ($MAX_SIZE_BYTES bytes). Reducing retention to ${retention_days} days." + continue + fi + fi + + find "${DIRECTORY}"/logs -type d -empty -delete || true + + retention_days="${RETENTION_DAYS}" + + seconds=$(( $(date -u +%s) % EVERY)) + (( seconds < 1 )) || sleep $((EVERY - seconds - 1)) + sleep 1 +done +EOF + +# The content below is automatically copied from scripts/docker/airflow-scheduler-autorestart.sh +COPY <<"EOF" /airflow-scheduler-autorestart.sh +#!/usr/bin/env bash + +while echo "Running"; do + airflow scheduler -n 5 + return_code=$? + if (( return_code != 0 )); then + echo "Scheduler crashed with exit code $return_code. Respawning.." >&2 + date >> /tmp/airflow_scheduler_errors.txt + fi + + sleep 1 +done +EOF + +############################################################################################## +# This is the build image where we build all dependencies +############################################################################################## +FROM ${BASE_IMAGE} as airflow-build-image + +# Nolog bash flag is currently ignored - but you can replace it with +# xtrace - to show commands executed) +SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "nounset", "-o", "nolog", "-c"] + +ARG BASE_IMAGE + +# Make sure noninteractive debian install is used and language variables set +ENV BASE_IMAGE=${BASE_IMAGE} \ + DEBIAN_FRONTEND=noninteractive LANGUAGE=C.UTF-8 LANG=C.UTF-8 LC_ALL=C.UTF-8 \ + LC_CTYPE=C.UTF-8 LC_MESSAGES=C.UTF-8 \ + PIP_CACHE_DIR=/tmp/.cache/pip \ + UV_CACHE_DIR=/tmp/.cache/uv + +ARG DEV_APT_DEPS="" +ARG ADDITIONAL_DEV_APT_DEPS="" +ARG DEV_APT_COMMAND="" +ARG ADDITIONAL_DEV_APT_COMMAND="" +ARG ADDITIONAL_DEV_APT_ENV="" +ARG AIRFLOW_PYTHON_VERSION + +ENV DEV_APT_DEPS=${DEV_APT_DEPS} \ + ADDITIONAL_DEV_APT_DEPS=${ADDITIONAL_DEV_APT_DEPS} \ + DEV_APT_COMMAND=${DEV_APT_COMMAND} \ + ADDITIONAL_DEV_APT_COMMAND=${ADDITIONAL_DEV_APT_COMMAND} \ + ADDITIONAL_DEV_APT_ENV=${ADDITIONAL_DEV_APT_ENV} \ + AIRFLOW_PYTHON_VERSION=${AIRFLOW_PYTHON_VERSION} + +ARG PYTHON_LTO + +ENV RUSTUP_HOME="/usr/local/rustup" +ENV CARGO_HOME="/home/airflow/.cargo" +ENV PATH="${CARGO_HOME}/bin:${PATH}" + +COPY --from=scripts install_os_dependencies.sh /scripts/docker/ +COPY scripts/docker/keys/ /scripts/docker/keys/ +RUN PYTHON_LTO=${PYTHON_LTO} bash /scripts/docker/install_os_dependencies.sh dev + +# In case system python is installed, setting LD_LIBRARY_PATH prevents any case the system python +# libraries will be accidentally used before the library installed from sources (which is newer and +# python interpreter might break if accidentally the old system libraries are used. +ENV LD_LIBRARY_PATH="/usr/python/lib" + +ARG INSTALL_MYSQL_CLIENT="false" +ARG INSTALL_MYSQL_CLIENT_TYPE="mariadb" +ARG INSTALL_MSSQL_CLIENT="false" +ARG INSTALL_POSTGRES_CLIENT="true" + +ENV INSTALL_MYSQL_CLIENT=${INSTALL_MYSQL_CLIENT} \ + INSTALL_MYSQL_CLIENT_TYPE=${INSTALL_MYSQL_CLIENT_TYPE} \ + INSTALL_MSSQL_CLIENT=${INSTALL_MSSQL_CLIENT} \ + INSTALL_POSTGRES_CLIENT=${INSTALL_POSTGRES_CLIENT} + +COPY --from=scripts common.sh /scripts/docker/ + +# Only copy mysql/mssql installation scripts for now - so that changing the other +# scripts which are needed much later will not invalidate the docker layer here +COPY --from=scripts install_mysql.sh install_mssql.sh install_postgres.sh /scripts/docker/ + +RUN bash /scripts/docker/install_mysql.sh dev && \ + bash /scripts/docker/install_mssql.sh dev && \ + bash /scripts/docker/install_postgres.sh dev +ENV PATH=${PATH}:/opt/mssql-tools/bin + +# By default we do not install from docker context files but if we decide to install from docker context +# files, we should override those variables to "docker-context-files" +ARG DOCKER_CONTEXT_FILES="Dockerfile" +ARG AIRFLOW_IMAGE_TYPE +ARG AIRFLOW_HOME +ARG AIRFLOW_USER_HOME_DIR +ARG AIRFLOW_UID + +RUN adduser --gecos "First Last,RoomNumber,WorkPhone,HomePhone" --disabled-password \ + --quiet "airflow" --uid "${AIRFLOW_UID}" --gid "0" --home "${AIRFLOW_USER_HOME_DIR}" && \ + mkdir -p ${AIRFLOW_HOME} && chown -R "airflow:0" "${AIRFLOW_USER_HOME_DIR}" ${AIRFLOW_HOME} + +COPY --chown=${AIRFLOW_UID}:0 ${DOCKER_CONTEXT_FILES} /docker-context-files + +USER airflow + +ARG AIRFLOW_REPO=apache/airflow +ARG AIRFLOW_BRANCH=main +ARG AIRFLOW_EXTRAS +ARG ADDITIONAL_AIRFLOW_EXTRAS="" +# Allows to override constraints source +ARG CONSTRAINTS_GITHUB_REPOSITORY="apache/airflow" +ARG AIRFLOW_CONSTRAINTS_MODE="constraints" +ARG AIRFLOW_CONSTRAINTS_REFERENCE="" +ARG AIRFLOW_CONSTRAINTS_LOCATION="" +ARG DEFAULT_CONSTRAINTS_BRANCH="constraints-main" +# By default do not fallback to installation without constraints because it can hide problems with constraints +ARG AIRFLOW_FALLBACK_NO_CONSTRAINTS_INSTALLATION="false" + +# By default PIP has progress bar but you can disable it. +ARG PIP_PROGRESS_BAR +# This is airflow version that is put in the label of the image build +ARG AIRFLOW_VERSION +# By default latest released version of airflow is installed (when empty) but this value can be overridden +# and we can install version according to specification (For example ==2.0.2 or <3.0.0). +ARG AIRFLOW_VERSION_SPECIFICATION +# Determines the way airflow is installed. By default we install airflow from PyPI `apache-airflow` package +# But it also can be `.` from local installation or GitHub URL pointing to specific branch or tag +# Of Airflow. Note That for local source installation you need to have local sources of +# Airflow checked out together with the Dockerfile and AIRFLOW_SOURCES_FROM and AIRFLOW_SOURCES_TO +# set to "." and "/opt/airflow" respectively. +ARG AIRFLOW_INSTALLATION_METHOD="apache-airflow" +# By default we do not upgrade to latest dependencies +ARG UPGRADE_RANDOM_INDICATOR_STRING="" +ARG AIRFLOW_SOURCES_FROM +ARG AIRFLOW_SOURCES_TO + +ENV AIRFLOW_USER_HOME_DIR=${AIRFLOW_USER_HOME_DIR} + +RUN if [[ -f /docker-context-files/pip.conf ]]; then \ + mkdir -p ${AIRFLOW_USER_HOME_DIR}/.config/pip; \ + cp /docker-context-files/pip.conf "${AIRFLOW_USER_HOME_DIR}/.config/pip/pip.conf"; \ + fi; \ + if [[ -f /docker-context-files/.piprc ]]; then \ + cp /docker-context-files/.piprc "${AIRFLOW_USER_HOME_DIR}/.piprc"; \ + fi + +# Additional PIP flags passed to all pip install commands except reinstalling pip itself +ARG ADDITIONAL_PIP_INSTALL_FLAGS="" + +ARG AIRFLOW_PIP_VERSION +ARG AIRFLOW_UV_VERSION +ARG AIRFLOW_USE_UV +ARG INCLUDE_PRE_RELEASE="false" + +ENV AIRFLOW_PIP_VERSION=${AIRFLOW_PIP_VERSION} \ + AIRFLOW_UV_VERSION=${AIRFLOW_UV_VERSION} \ + AIRFLOW_USE_UV=${AIRFLOW_USE_UV} \ + AIRFLOW_VERSION=${AIRFLOW_VERSION} \ + AIRFLOW_INSTALLATION_METHOD=${AIRFLOW_INSTALLATION_METHOD} \ + AIRFLOW_VERSION_SPECIFICATION=${AIRFLOW_VERSION_SPECIFICATION} \ + AIRFLOW_SOURCES_FROM=${AIRFLOW_SOURCES_FROM} \ + AIRFLOW_SOURCES_TO=${AIRFLOW_SOURCES_TO} \ + AIRFLOW_REPO=${AIRFLOW_REPO} \ + AIRFLOW_BRANCH=${AIRFLOW_BRANCH} \ + AIRFLOW_EXTRAS=${AIRFLOW_EXTRAS}${ADDITIONAL_AIRFLOW_EXTRAS:+,}${ADDITIONAL_AIRFLOW_EXTRAS} \ + CONSTRAINTS_GITHUB_REPOSITORY=${CONSTRAINTS_GITHUB_REPOSITORY} \ + AIRFLOW_CONSTRAINTS_MODE=${AIRFLOW_CONSTRAINTS_MODE} \ + AIRFLOW_CONSTRAINTS_REFERENCE=${AIRFLOW_CONSTRAINTS_REFERENCE} \ + AIRFLOW_CONSTRAINTS_LOCATION=${AIRFLOW_CONSTRAINTS_LOCATION} \ + AIRFLOW_FALLBACK_NO_CONSTRAINTS_INSTALLATION=${AIRFLOW_FALLBACK_NO_CONSTRAINTS_INSTALLATION} \ + DEFAULT_CONSTRAINTS_BRANCH=${DEFAULT_CONSTRAINTS_BRANCH} \ + PATH=${AIRFLOW_USER_HOME_DIR}/.local/bin:${PATH} \ + PIP_PROGRESS_BAR=${PIP_PROGRESS_BAR} \ + ADDITIONAL_PIP_INSTALL_FLAGS=${ADDITIONAL_PIP_INSTALL_FLAGS} \ + AIRFLOW_HOME=${AIRFLOW_HOME} \ + AIRFLOW_IMAGE_TYPE=${AIRFLOW_IMAGE_TYPE} \ + AIRFLOW_UID=${AIRFLOW_UID} \ + INCLUDE_PRE_RELEASE=${INCLUDE_PRE_RELEASE} \ + UPGRADE_RANDOM_INDICATOR_STRING=${UPGRADE_RANDOM_INDICATOR_STRING} + + +# Copy all scripts required for installation - changing any of those should lead to +# rebuilding from here +COPY --from=scripts common.sh install_packaging_tools.sh create_prod_venv.sh /scripts/docker/ + +# We can set this value to true in case we want to install .whl/.tar.gz packages placed in the +# docker-context-files folder. This can be done for both additional packages you want to install +# as well as Airflow and provider distributions (it will be automatically detected if airflow +# is installed from docker-context files rather than from PyPI) +ARG INSTALL_DISTRIBUTIONS_FROM_CONTEXT="false" + +# Normally constraints are not used when context packages are build - because we might have packages +# that are conflicting with Airflow constraints, however there are cases when we want to use constraints +# for example in CI builds when we already have source-package constraints - either from github branch or +# from eager-upgraded constraints by the CI builds +ARG USE_CONSTRAINTS_FOR_CONTEXT_DISTRIBUTIONS="false" + +# In case of Production build image segment we want to pre-install main version of airflow +# dependencies from GitHub so that we do not have to always reinstall it from the scratch. +# The Airflow and providers are uninstalled, only dependencies remain +# the cache is only used when "upgrade to newer dependencies" is not set to automatically +# account for removed dependencies (we do not install them in the first place) and in case +# INSTALL_DISTRIBUTIONS_FROM_CONTEXT is not set (because then caching it from main makes no sense). + +# By default PIP installs everything to ~/.local and it's also treated as VIRTUALENV +ENV VIRTUAL_ENV="${AIRFLOW_USER_HOME_DIR}/.local" +ENV PATH="/usr/python/bin:$PATH" +RUN bash /scripts/docker/install_packaging_tools.sh; bash /scripts/docker/create_prod_venv.sh + +COPY --chown=airflow:0 ${AIRFLOW_SOURCES_FROM} ${AIRFLOW_SOURCES_TO} + +# Add extra python dependencies +ARG ADDITIONAL_PYTHON_DEPS="" + + +ARG VERSION_SUFFIX="" + +ENV ADDITIONAL_PYTHON_DEPS=${ADDITIONAL_PYTHON_DEPS} \ + INSTALL_DISTRIBUTIONS_FROM_CONTEXT=${INSTALL_DISTRIBUTIONS_FROM_CONTEXT} \ + USE_CONSTRAINTS_FOR_CONTEXT_DISTRIBUTIONS=${USE_CONSTRAINTS_FOR_CONTEXT_DISTRIBUTIONS} \ + VERSION_SUFFIX=${VERSION_SUFFIX} + +WORKDIR ${AIRFLOW_HOME} + +COPY --from=scripts install_from_docker_context_files.sh install_airflow_when_building_images.sh \ + install_additional_dependencies.sh create_prod_venv.sh get_distribution_specs.py /scripts/docker/ + +# Useful for creating a cache id based on the underlying architecture, preventing the use of cached python packages from +# an incorrect architecture. +ARG TARGETARCH +# Value to be able to easily change cache id and therefore use a bare new cache +ARG DEPENDENCY_CACHE_EPOCH="11" + +# hadolint ignore=SC2086, SC2010, DL3042 +RUN --mount=type=cache,id=prod-$TARGETARCH-$DEPENDENCY_CACHE_EPOCH,target=/tmp/.cache/,uid=${AIRFLOW_UID} \ + if [[ ${INSTALL_DISTRIBUTIONS_FROM_CONTEXT} == "true" ]]; then \ + bash /scripts/docker/install_from_docker_context_files.sh; \ + fi; \ + if ! airflow version 2>/dev/null >/dev/null; then \ + bash /scripts/docker/install_airflow_when_building_images.sh; \ + fi; \ + if [[ -n "${ADDITIONAL_PYTHON_DEPS}" ]]; then \ + bash /scripts/docker/install_additional_dependencies.sh; \ + fi; \ + find "${AIRFLOW_USER_HOME_DIR}/.local/" -name '*.pyc' -print0 | xargs -0 rm -f || true ; \ + find "${AIRFLOW_USER_HOME_DIR}/.local/" -type d -name '__pycache__' -print0 | xargs -0 rm -rf || true ; \ + # make sure that all directories and files in .local are also group accessible + find "${AIRFLOW_USER_HOME_DIR}/.local" -executable ! -type l -print0 | xargs --null chmod g+x; \ + find "${AIRFLOW_USER_HOME_DIR}/.local" ! -type l -print0 | xargs --null chmod g+rw + +# In case there is a requirements.txt file in "docker-context-files" it will be installed +# during the build additionally to whatever has been installed so far. It is recommended that +# the requirements.txt contains only dependencies with == version specification +# hadolint ignore=DL3042 +RUN --mount=type=cache,id=prod-$TARGETARCH-$DEPENDENCY_CACHE_EPOCH,target=/tmp/.cache/,uid=${AIRFLOW_UID} \ + if [[ -f /docker-context-files/requirements.txt ]]; then \ + pip install -r /docker-context-files/requirements.txt; \ + find "${AIRFLOW_USER_HOME_DIR}/.local/" -name '*.pyc' -print0 | xargs -0 rm -f || true ; \ + find "${AIRFLOW_USER_HOME_DIR}/.local/" -type d -name '__pycache__' -print0 | xargs -0 rm -rf || true ; \ + # make sure that all directories and files in .local are also group accessible + find "${AIRFLOW_USER_HOME_DIR}/.local" -executable ! -type l -print0 | xargs --null chmod g+x; \ + find "${AIRFLOW_USER_HOME_DIR}/.local" ! -type l -print0 | xargs --null chmod g+rw; \ + fi + +############################################################################################## +# This is the actual Airflow image - much smaller than the build one. We copy +# installed Airflow and all its dependencies from the build image to make it smaller. +############################################################################################## +FROM ${BASE_IMAGE} as main + +# Nolog bash flag is currently ignored - but you can replace it with other flags (for example +# xtrace - to show commands executed) +SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "nounset", "-o", "nolog", "-c"] + +ARG AIRFLOW_UID + +LABEL org.apache.airflow.distro="debian" \ + org.apache.airflow.module="airflow" \ + org.apache.airflow.component="airflow" \ + org.apache.airflow.image="airflow" \ + org.apache.airflow.uid="${AIRFLOW_UID}" + +ARG BASE_IMAGE + +# Make sure noninteractive debian install is used and language variables set +ENV BASE_IMAGE=${BASE_IMAGE} \ + DEBIAN_FRONTEND=noninteractive LANGUAGE=C.UTF-8 LANG=C.UTF-8 LC_ALL=C.UTF-8 \ + LC_CTYPE=C.UTF-8 LC_MESSAGES=C.UTF-8 \ + PIP_CACHE_DIR=/tmp/.cache/pip \ + UV_CACHE_DIR=/tmp/.cache/uv + +ARG RUNTIME_APT_DEPS="" +ARG ADDITIONAL_RUNTIME_APT_DEPS="" +ARG RUNTIME_APT_COMMAND="echo" +ARG ADDITIONAL_RUNTIME_APT_COMMAND="" +ARG ADDITIONAL_RUNTIME_APT_ENV="" +ARG INSTALL_MYSQL_CLIENT="true" +ARG INSTALL_MYSQL_CLIENT_TYPE="mariadb" +ARG INSTALL_MSSQL_CLIENT="true" +ARG INSTALL_POSTGRES_CLIENT="true" +ARG AIRFLOW_INSTALLATION_METHOD="apache-airflow" + +ENV RUNTIME_APT_DEPS=${RUNTIME_APT_DEPS} \ + ADDITIONAL_RUNTIME_APT_DEPS=${ADDITIONAL_RUNTIME_APT_DEPS} \ + RUNTIME_APT_COMMAND=${RUNTIME_APT_COMMAND} \ + ADDITIONAL_RUNTIME_APT_COMMAND=${ADDITIONAL_RUNTIME_APT_COMMAND} \ + INSTALL_MYSQL_CLIENT=${INSTALL_MYSQL_CLIENT} \ + INSTALL_MYSQL_CLIENT_TYPE=${INSTALL_MYSQL_CLIENT_TYPE} \ + INSTALL_MSSQL_CLIENT=${INSTALL_MSSQL_CLIENT} \ + INSTALL_POSTGRES_CLIENT=${INSTALL_POSTGRES_CLIENT} \ + GUNICORN_CMD_ARGS="--worker-tmp-dir /dev/shm" \ + AIRFLOW_INSTALLATION_METHOD=${AIRFLOW_INSTALLATION_METHOD} + +ARG PYTHON_LTO + +COPY --from=airflow-build-image "/usr/python/" "/usr/python/" +COPY --from=scripts install_os_dependencies.sh /scripts/docker/ +RUN bash /scripts/docker/install_os_dependencies.sh runtime + +# Having the variable in final image allows to disable providers manager warnings when +# production image is prepared from sources rather than from package +ARG AIRFLOW_IMAGE_REPOSITORY +ARG AIRFLOW_IMAGE_README_URL +ARG AIRFLOW_USER_HOME_DIR +ARG AIRFLOW_HOME +ARG AIRFLOW_IMAGE_TYPE + +# By default PIP installs everything to ~/.local +ENV PATH="${AIRFLOW_USER_HOME_DIR}/.local/bin:/usr/python/bin:${PATH}" \ + VIRTUAL_ENV="${AIRFLOW_USER_HOME_DIR}/.local" \ + AIRFLOW_UID=${AIRFLOW_UID} \ + AIRFLOW_USER_HOME_DIR=${AIRFLOW_USER_HOME_DIR} \ + AIRFLOW_HOME=${AIRFLOW_HOME} \ + AIRFLOW_IMAGE_TYPE=${AIRFLOW_IMAGE_TYPE} + +COPY --from=scripts common.sh /scripts/docker/ + +COPY scripts/docker/keys/ /scripts/docker/keys/ + +# Only copy mysql/mssql installation scripts for now - so that changing the other +# scripts which are needed much later will not invalidate the docker layer here. +COPY --from=scripts install_mysql.sh install_mssql.sh install_postgres.sh /scripts/docker/ +# We run scripts with bash here to make sure we can execute the scripts. Changing to +x might have an +# unexpected result - the cache for Dockerfiles might get invalidated in case the host system +# had different umask set and group x bit was not set. In Azure the bit might be not set at all. +# That also protects against AUFS Docker backend problem where changing the executable bit required sync +RUN bash /scripts/docker/install_mysql.sh prod \ + && bash /scripts/docker/install_mssql.sh prod \ + && bash /scripts/docker/install_postgres.sh prod \ + && adduser --gecos "First Last,RoomNumber,WorkPhone,HomePhone" --disabled-password \ + --quiet "airflow" --uid "${AIRFLOW_UID}" --gid "0" --home "${AIRFLOW_USER_HOME_DIR}" \ +# Make Airflow files belong to the root group and are accessible. This is to accommodate the guidelines from +# OpenShift https://docs.openshift.com/enterprise/3.0/creating_images/guidelines.html + && mkdir -pv "${AIRFLOW_HOME}" \ + && mkdir -pv "${AIRFLOW_HOME}/dags" \ + && mkdir -pv "${AIRFLOW_HOME}/logs" \ + && chown -R airflow:0 "${AIRFLOW_USER_HOME_DIR}" "${AIRFLOW_HOME}" \ + && chmod -R g+rw "${AIRFLOW_USER_HOME_DIR}" "${AIRFLOW_HOME}" \ + && find "${AIRFLOW_USER_HOME_DIR}" -name '*.pyc' -print0 | xargs -0 rm -f || true \ + && find "${AIRFLOW_USER_HOME_DIR}" -type d -name '__pycache__' -print0 | xargs -0 rm -rf || true \ + && find "${AIRFLOW_HOME}" -executable ! -type l -print0 | xargs --null chmod g+x \ + && find "${AIRFLOW_USER_HOME_DIR}" -executable ! -type l -print0 | xargs --null chmod g+x + +ARG AIRFLOW_SOURCES_FROM +ARG AIRFLOW_SOURCES_TO + +COPY --from=airflow-build-image --chown=airflow:0 \ + "${AIRFLOW_USER_HOME_DIR}/.local" "${AIRFLOW_USER_HOME_DIR}/.local" +COPY --from=airflow-build-image --chown=airflow:0 \ + "${AIRFLOW_USER_HOME_DIR}/constraints.txt" "${AIRFLOW_USER_HOME_DIR}/constraints.txt" +# In case of editable build also copy airflow sources so that they are available in the main image +# For regular image (non-editable) this will be just Dockerfile copied to /Dockerfile +COPY --from=airflow-build-image --chown=airflow:0 "${AIRFLOW_SOURCES_TO}" "${AIRFLOW_SOURCES_TO}" + +COPY --from=scripts entrypoint_prod.sh /entrypoint +COPY --from=scripts clean-logs.sh /clean-logs +COPY --from=scripts airflow-scheduler-autorestart.sh /airflow-scheduler-autorestart + +# Make /etc/passwd root-group-writeable so that user can be dynamically added by OpenShift +# See https://github.com/apache/airflow/issues/9248 +# Set default groups for airflow and root user + +RUN chmod a+rx /entrypoint /clean-logs \ + && chmod g=u /etc/passwd \ + && chmod g+w "${AIRFLOW_USER_HOME_DIR}/.local" \ + && usermod -g 0 airflow -G 0 + +# make sure that the venv is activated for all users +# including plain sudo, sudo with --interactive flag +RUN sed --in-place=.bak "s/secure_path=\"/secure_path=\"$(echo -n ${AIRFLOW_USER_HOME_DIR} | \ + sed 's/\//\\\//g')\/.local\/bin:/" /etc/sudoers + +ARG AIRFLOW_VERSION +ARG AIRFLOW_PIP_VERSION +ARG AIRFLOW_UV_VERSION +ARG AIRFLOW_USE_UV +ARG AIRFLOW_PYTHON_VERSION + +# See https://airflow.apache.org/docs/docker-stack/entrypoint.html#signal-propagation +# to learn more about the way how signals are handled by the image +# Also set airflow as nice PROMPT message. +ENV DUMB_INIT_SETSID="1" \ + PS1="(airflow)" \ + AIRFLOW_VERSION=${AIRFLOW_VERSION} \ + AIRFLOW_PYTHON_VERSION=${AIRFLOW_PYTHON_VERSION} \ + AIRFLOW__CORE__LOAD_EXAMPLES="false" \ + PATH="/root/bin:${PATH}" \ + AIRFLOW_PIP_VERSION=${AIRFLOW_PIP_VERSION} \ + AIRFLOW_UV_VERSION=${AIRFLOW_UV_VERSION} \ + AIRFLOW_USE_UV=${AIRFLOW_USE_UV} + +# Add protection against running pip as root user +RUN mkdir -pv /root/bin +COPY --from=scripts pip /root/bin/pip +RUN chmod u+x /root/bin/pip + +WORKDIR ${AIRFLOW_HOME} + +EXPOSE 8080 + +USER ${AIRFLOW_UID} + +# Those should be set and used as late as possible as any change in commit/build otherwise invalidates the +# layers right after +ARG BUILD_ID +ARG COMMIT_SHA +ARG AIRFLOW_IMAGE_REPOSITORY +ARG AIRFLOW_IMAGE_DATE_CREATED + +ENV BUILD_ID=${BUILD_ID} COMMIT_SHA=${COMMIT_SHA} + +LABEL org.apache.airflow.distro="debian" \ + org.apache.airflow.module="airflow" \ + org.apache.airflow.component="airflow" \ + org.apache.airflow.image="airflow" \ + org.apache.airflow.version="${AIRFLOW_VERSION}" \ + org.apache.airflow.python.version="${AIRFLOW_PYTHON_VERSION}" \ + org.apache.airflow.uid="${AIRFLOW_UID}" \ + org.apache.airflow.main-image.build-id="${BUILD_ID}" \ + org.apache.airflow.main-image.commit-sha="${COMMIT_SHA}" \ + org.opencontainers.image.source="${AIRFLOW_IMAGE_REPOSITORY}" \ + org.opencontainers.image.created=${AIRFLOW_IMAGE_DATE_CREATED} \ + org.opencontainers.image.authors="dev@airflow.apache.org" \ + org.opencontainers.image.url="https://airflow.apache.org" \ + org.opencontainers.image.documentation="https://airflow.apache.org/docs/docker-stack/index.html" \ + org.opencontainers.image.version="${AIRFLOW_VERSION}" \ + org.opencontainers.image.revision="${COMMIT_SHA}" \ + org.opencontainers.image.vendor="Apache Software Foundation" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.ref.name="airflow" \ + org.opencontainers.image.title="Production Airflow Image" \ + org.opencontainers.image.description="Reference, production-ready Apache Airflow image" + +ENTRYPOINT ["/usr/bin/dumb-init", "--", "/entrypoint"] +CMD [] diff --git a/docker/airflow-base/README.md b/docker/airflow-base/README.md new file mode 100644 index 00000000..239b05e0 --- /dev/null +++ b/docker/airflow-base/README.md @@ -0,0 +1,41 @@ +# Apache Airflow base image (Debian Trixie) + +Apache only publishes `apache/airflow` images based on Debian **bookworm**. To run +Airflow on Debian **Trixie** we build the base image ourselves from the **official +Airflow Dockerfile**, which compiles Python from source on top of a `debian:*-slim` +base image (build arg `BASE_IMAGE`). + +## Contents +- `Dockerfile` — verbatim copy of the official Airflow Dockerfile, tag `3.2.2` + (https://raw.githubusercontent.com/apache/airflow/3.2.2/Dockerfile). +- `scripts/docker/keys/` — apt/Python signing keys referenced by the Dockerfile, + copied from the same tag. + +## Build +```bash +make build-airflow-base +# equivalent to: +docker build \ + --build-arg BASE_IMAGE=debian:trixie-slim \ + --build-arg AIRFLOW_VERSION=3.2.2 \ + --build-arg AIRFLOW_PYTHON_VERSION=3.14.0 \ + -t datafeeder-airflow-base:3.2.2-trixie \ + docker/airflow-base +``` + +The resulting `datafeeder-airflow-base:3.2.2-trixie` image is consumed as the `base` +stage of `docker/Dockerfile.airflow` (build arg `AIRFLOW_BASE_IMAGE`). + +## Upgrading +1. Download the official Dockerfile and `scripts/docker/keys/` for the new tag. +2. Bump `AIRFLOW_VERSION` / `AIRFLOW_PYTHON_VERSION` in the `Makefile` and + `apps/elt/pyproject.toml`, then regenerate `apps/elt/uv.lock`. + +## Trixie patch +Two minimal changes are applied to the upstream `DEV_APT_DEPS` list for Trixie +compatibility (re-apply them when refreshing from upstream): +- removed `lzma-dev`: bookworm-only transitional package, gone on Trixie and + fully covered by `liblzma-dev`. +- removed `lcov`: on Trixie it depends on the system Python (`libpython3.13`), + which the official Dockerfile forbids (Python is compiled from source). `lcov` + is only a coverage tool and is not needed to build/run the image. diff --git a/docker/airflow-base/scripts/docker/keys/mariadb.asc b/docker/airflow-base/scripts/docker/keys/mariadb.asc new file mode 100644 index 00000000..d35c5a49 --- /dev/null +++ b/docker/airflow-base/scripts/docker/keys/mariadb.asc @@ -0,0 +1,104 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsFNBFb8EKsBEADwGmleOSVThrbCyCVUdCreMTKpmD5p5aPz/0jc66050MAb71Hv +TVcfuMqHYO8O66qXLpEdqZpuk4D+rw1oKyC+d8uPD2PSHRqBXnR0Qf+LVTZvtO92 +3R7pYnC2x6V6iVGpKQYFP8cwh2B1qgIa+9y/N8cQIqfD+0ghyiUjjTYek3YFBnqa +L/2h2V0Mt0DkBrDK80LqEY10PAFDfJjINAW9XNHZzi2KqUx5w1z8rItokXV6fYE5 +ItyGMR6WVajJg5D4VCiZd0ymuQP2bGkrRbl6FH5vofVSkahKMJeHs2lbvMvNyS3c +n8vxoBvbbcwSAV1gvB1uzXXxv0kdkFZjhU1Tss4+Dak8qeEmIrC5qYycLxIdVEhT +Z8N8+P7Dll+QGOZKu9+OzhQ+byzpLFhUHKys53eXo/HrfWtw3DdP21yyb5P3QcgF +scxfZHzZtFNUL6XaVnauZM2lqquUW+lMNdKKGCBJ6co4QxjocsxfISyarcFj6ZR0 +5Hf6VU3Y7AyuFZdL0SQWPv9BSu/swBOimrSiiVHbtE49Nx1x/d1wn1peYl07WRUv +C10eF36ZoqEuSGmDz59mWlwB3daIYAsAAiBwgcmN7aSB8XD4ZPUVSEZvwSm/IwuS +Rkpde+kIhTLjyv5bRGqU2P/Mi56dB4VFmMJaF26CiRXatxhXOAIAF9dXCwARAQAB +zS1NYXJpYURCIFNpZ25pbmcgS2V5IDxzaWduaW5nLWtleUBtYXJpYWRiLm9yZz7C +wXgEEwEIACIFAlb8EKsCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEPFl +byTHTNHYJZ0P/2Z2RURRkSTHLKZ/GqSvPReReeB7AI+ZrDapkpG/26xp1Yw1isCO +y99pvQ7hjTFhdZQ7xSRUiT/e27wJxR7s4G/ck5VOVjuJzGnByNLmwMjdN1ONIO9P +hQAs2iF3uoIbVTxzXof2F8C0WSbKgEWbtqlCWlaapDpN8jKAWdsQsNMdXcdpJ2os +WiacQRxLREBGjVRkAiqdjYkegQ4BZ0GtPULKjZWCUNkaat51b7O7V19nSy/T7MM7 +n+kqYQLMIHCF8LGd3QQsNppRnolWVRzXMdtR2+9iI21qv6gtHcMiAg6QcKA7halL +kCdIS2nWR8g7nZeZjq5XhckeNGrGX/3w/m/lwczYjMUer+qs2ww5expZJ7qhtSta +lE3EtL/l7zE4RlknqwDZ0IXtxCNPu2UovCzZmdZm8UWfMSKk/3VgL8HgzYRr8fo0 +yj0XkckJ7snXvuhoviW2tjm46PyHPWRKgW4iEzUrB+hiXpy3ikt4rLRg/iMqKjyf +mvcE/VdmFVtsfbfRVvlaWiIWCndRTVBkAaTu8DwrGyugQsbjEcK+4E25/SaKIJIw +qfxpyBVhru21ypgEMAw1Y8KC7KntB7jzpFotE4wpv1jZKUZuy71ofr7g3/2O+7nW +LrR1mncbuT6yXo316r56dfKzOxQJBnYFwTjXfa65yBArjQBUCPNYOKr0wkYEEhEI +AAYFAlb8JFYACgkQy8sIKhu5Q9snYACgh3id41CYTHELOQ/ymj4tiuFt1lcAn3JU +9wH3pihM9ISvoeuGnwwHhcKnwsFcBBIBCAAGBQJW/CSEAAoJEJFxGJmV5Fqe11cP +/A3QhvqleuRaXoS5apIY3lrDL79Wo0bkydM3u2Ft9EqVVG5zZvlmWaXbw5wkPhza +7YUjrD7ylaE754lHI48jJp3KY7RosClY/Kuk56GJI/SoMKx4v518pAboZ4hjY9MY +gmiAuZEYx5Ibv1pj0+hkzRI78+f6+d5QTQ6y/35ZjSSJcBgCMAr/JRsmOkHu6cY6 +qOpq4g8mvRAX5ivRm4UxE2gnxZyd2LjY2/S2kCZvHWVaZuiTD0EU1jYPoOo6fhc8 +zjs5FWS56C1vp7aFOGBvsH3lwYAYi1K2S+/B4nqpitYJz/T0zFzzyYe7ZG77DXKD +/XajD22IzRGKjoeVPFBx+2V0YCCpWZkqkfZ2Dt3QVW//QIpVsOJnmaqolDg1sxoa +BEYBtCtovU0wh1pXWwfn7IgjIkPNl0AU8mW8Ll91WF+Lss/oMrUJMKVDenTJ6/ZO +06c+JFlP7dS3YGMsifwgy5abA4Xy4GWpAsyEM68mqsJUc7ZANZcQAKr6+DryzSfI +Olsn3kJzOtb/c3JhVmblEO6XzdfZJK/axPOp3mF1oEBoJ56fGwO2usgVwQDyLt3J +iluJrCvMSBL9KtBZWrTZH5t3rTMN0NUALy4Etd6Y8V94i8c5NixMDyjRU7aKJAAw +tUvxLd12dqtaXsuvGyzLbR4EDT/Q5DfLC1DZWpgtUtCVwsFcBBIBCAAGBQJW/CS2 +AAoJEEHdwLQNpW8iMUoP/AjFKyZ+inQTI2jJJBBtrLjxaxZSG5ggCovowWn8NWv6 +bQBm2VurYVKhvY1xUyxoLY8KN+MvoeTdpB3u7z+M6x+CdfoTGqWQ2yapOC0eEJBF +O+GFho2WE0msiO0IaVJrzdFTPE0EYR2BHziLu0DDSZADe1WYEqkkrZsCNgi6EMng +mX2h+DK2GlC3W2tY9sc63DsgzjcMBO9uYmpHj6nizsIrETqouVNUCLT0t8iETa25 +Mehq/I92I70Qfebv7R4eMrs+tWXKyPU0OjV+8b8saZsv1xn98UkeXwYx4JI04OTw +nBeJG8yPrGDBO5iucmtaCvwGQ3c76qBivrA8eFz3azRxQYWWiFrkElTg+C/E83JQ +WgqPvPZkI5UHvBwBqcoIXG15AJoXA/ZWIB8nPKWKaV5KDnY3DBuA4rh5Mhy3xwcC +/22E/CmZMXjUUvDnlPgXCYAYU0FBbGk7JpSYawtNfdAN2XBRPq5sDKLLxftx7D8u +ESJXXAlPxoRh7x1ArdGM+EowlJJ0xpINBaT0Z/Hk0jxNIFEak796/WeGqewdOIki +dAs4tppUfzosla5K+qXfWwmhcKmpwA4oynE8wIaoXptoi8+rxaw4N6wAXlSrVxeC +VTnb7+UY/BT2Wx6IQ10C9jrsj6XIffMvngIinCD9Czvadmr7BEIxKt1LP+gGA8Zg +wsFcBBIBCgAGBQJYE6oDAAoJEL7YRJ/O6NqIJ24P+QFNa2O+Q1rLKrQiuPw4Q73o +7/blUpFNudZfeCDpDbUgJ01u1RHnWOyLcyknartAosFDJIpgcXY5I8jsBIO5IZPR +C/UKxZB3RYOhj49bySD9RNapHyq+Y56j9JUoz6tkKFBd+6g85Ej8d924xM1UnRCS +9cfI9W0fSunbCi2CXLbXFF7V+m3Ou1SVYGIAxpMn4RXyYfuqeB5wROR2GA5Ef6T3 +S5byh1dRSEgnrBToENtp5n7Jwsc9pDofjtaUkO854l45IqFarGjCHZwtNRKd2lcK +FMnd1jS0nfGkUbn3qNJam1qaGWx4gXaT845VsYYVTbxtkKi+qPUIoOyYx4NEm6fC +ZywH72oP+fmUT/fbfSHa5j137dRqokkR6RFjnEMBl6WHwgqqUqeIT6t9uV6WWzX9 +lNroZFAFL/de7H31iIRuZcm38DUZOfjVf9glweu4yFvuJ7cQtyQydFQJV4LGDT/C +8e9TWrV1/gWMyMGQlZsRWa+h+FfFUccQtfSdXpvSxtXfop+fVQmJgUUl92jh4K9j +c9a6rIp5v1Q1yEgs2iS50/V/NMSmEcE1XMOxFt9fX9T+XmKAWZ8L25lpILsHT3mB +VWrpHdbawUaiBp9elxhn6tFiTFR7qA7dlUyWrI+MMlINwSZ2AAXvmA2IajH/UIlh +xotxmSNiZYIQ6UbD3fk4wsFzBBABCgAdFiEEmy/52H2krRdju+d2+GQcuhDvLUgF +Ally44wACgkQ+GQcuhDvLUgkjQ//c3mBxfJm6yLAJD4s4OgsPv4pcp/EKmPcdztm +W0/glwopUZmq9oNo3VMMCGtusrQgpACzfUlesu9NWlPCB3olZkeGugygo0zuQBKs +55eG7bPzMLyfSqLKyogYocaGc4lpf4lbvlvxy37YGVrGpwT9i8t2REtM6iPKDcMM +sgVtNlqFdq3Fs2Haqt0m1EksX6/GSIrjK4LZEcPklrGPvUS3S+qkwuaGE/jXxncE +4jFQR9SYH6AHr6Vkt1CG9Dgpr+Ph0I9n0JRknBYoUZ1q51WdF946NplXkCskdzWG +RHgMUCz3ZehF1FzpKgfO9Zd0YZsmivV/g6frUw/TayP9gxKPt7z2Lsxzyh8X7cg6 +TAvdG9JbG0PyPJT1TZ8qpjP/PtqPclHsHQQIbGSDFWzRM5znhS+5sgyw8FWInjw8 +JjxoOWMa50464EfGeb2jZfwtRimJAJLWEf/JnvO779nXf5YbvUZgfXaX7k/cvCVk +U8M7oC7x8o6F0P2Lh6FgonklKEeIRtZBUNZ0Lk9OShVqlU9/v16MHq/Eyu/Mbs0D +en3vYgiYxOBR8czD1Wh4vsKiGfOzQ6oWti/DCURV+iTYhJc7mSWM6STzUFr0nCnF +x6W0j/zH6ZgiFAGOyIXW2DwfjFvYRcBL1RWAEKsiFwYrNV+MDonjKXjpVB1Ra90o +lLrZXAXCwHMEEgEKAB0WIQRMRw//78TT3Fl3hlXOGj3V48lPSQUCXAAgOgAKCRDO +Gj3V48lPSQxAB/43qoWteVZEiN3JW4FnHg+S60TnHSP69FKV+363XYKDa23pNpv4 +tiJumo9Kvb4UoDft766/URHm5RKyPtrxy+wqotamrkGJUTtP2a68h7C31VX+pf6i +iQKmxRQz4zmW0pA5X01+AgpvcDH++Fv5NLBpnjqPdTh5b0gvr89E0zMNldNYOZu1 +0H/mukrnGlFDu/osBuy+XJtP2MeasazVMLvjKs+hr//E+iLI9DZOwFBK6AX5gkkI +UEHkSeb4//AHwvanUMin9un9+F9iR+qDuDEKxuevYzM0owuoVcK5pAsRnRQJlnHW +/0BQ6FtNGpmljhvUk8a/l3xFf3z/uJG5vVKVzsFNBFb8EKsBEADDfCMsu2U1CdJh +r4xp6z4J89/tMnpCQASC8DQhtZ6bWG/ksyKt2DnDQ050XBEng+7epzHWA2UgT0li +Y05zZmFs1X7QeZr16B7JANq6fnHOdZB0ThS7JEYbProkMxcqAFLAZJCpZT534Gpz +W7qHwzjV+d13IziCHdi6+DD5eavYzBqY8QzjlOXbmIlY7dJUCwXTECUfirc6kH86 +CS8fXZTke4QYZ55VnrOomB4QGqP371kwBETnhlhi74+pvi3jW05Z5x1tVMwuugyz +zkseZp1VYmJq5SHNFZ/pnAQLE9gUDTb6UWcPBwQh9Sw+7ahSK74lJKYm3wktyvZh +zAxbNyzs1M56yeFP6uFwJTBfNByyMAa6TGUhNkxlLcYjxKbVmoAnKCVM8t41TlLv +/a0ki8iQxqvphVLufksR9IpN6d3F15j6GeyVtxBEv04iv4vbuKthWytb+gjX4bI8 +CAo9jGHevmtdiw/SbeKx2YBM1MF6eua37rFMooOBj4X7VfQCyS+crNsOQn8nJGah +YbzUDCCgnX+pqN9iZvXisMS79wVyD5DyISFDvT/5jY7IXxPibxr10P/8lfW1d72u +xyI2UiZKZpyHCt4k47yMq4KQGLGuhxJ6q6O3bi2aXRuz8bLqTBLca9dmx9wZFvRh +6jS/SKEg7eFcY0xbb6RVIv1UwGDYfQARAQABwsFfBBgBCAAJBQJW/BCrAhsMAAoJ +EPFlbyTHTNHYEBIQAJhFTh1u34Q+5bnfiM2dAdCr6T6w4Y1v9ePiIYdSImeseJS2 +yRglpLcMjW0uEA9KXiRtC/Nm/ClnqYJzCKeIaweHqH6dIgJKaXZFt1Uaia7X9tDD +wqALGu97irUrrV1Kh9IkM0J29Vid5amakrdS4mwt2uEISSnCi7pfVoEro+S7tYQ9 +iH6APVIwqWvcaty3cANdwKWfUQZ6a9IQ08xqzaMhMp2VzhVrWkq3B0j2aRoZR7BN +LH2I7Z0giIM8ARjZs99aTRL+SfMEQ3sUxNLb3KWP/n1lSFbrk4HGzqUBBfczESlN +c0970C6znK0H0HD11/3BTkMuPqww+Tzex4dpMQllMEKZ3wEyd9v6ba+nj/P1FHSE +y/VN6IXzd82s1lYOonKTdmXAIROcHnb0QUzwsd/mhB3jKhEDOV2ZcBTD3yHv8m7C +9G9y4hV+7yQlnPlSg3DjBp3SS5r+sOObCIy2Ad32upoXkilWa9g7GZSuhY9kyKqe +Eba1lgXXaQykEeqx0pexkWavNnb9JaPrAZHDjUGcXrREmjEyXyElRoD4CrWXySe4 +6jCuNhVVlkLGo7osefynXa/+PNjQjURtx8en7M9A1FkQuRAxE8KIZgZzYxkGl5o5 +POSFCA4JUoRPDcrl/sI3fuq2dIOE/BJ2r8dV+LddiR+iukhXRwJXH8RVVEUS +=mCOI +-----END PGP PUBLIC KEY BLOCK----- diff --git a/docker/airflow-base/scripts/docker/keys/microsoft.asc b/docker/airflow-base/scripts/docker/keys/microsoft.asc new file mode 100644 index 00000000..0c8be68d --- /dev/null +++ b/docker/airflow-base/scripts/docker/keys/microsoft.asc @@ -0,0 +1,42 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGVUhiwBEADF3TWX0HMi2+BdQfJrSdQkZTE4qk4vV2ooAMn8vWA2DGI88JOl +k1LwhZGEqJv5TsKTyNEMWb3NXhR1ZZ5uQPvf6iN0806cq83s096F85GUtjzfGLQj +Zo3FhDSKeHz3mhthQ4QP4bwYUmSpWs6e+/ZSFYYc3yU8mInDM4SNzrqr4x2ltmf+ +3RWkoYYo1SpG521A9+1zi7xzz6IHpAk6MdIcTj7mHxXd6ovmXkvHUhKbXGkybHPn +iupWokDaJZgV4+q6kc7zVgTVnwmXV7NHQhWSyOm/BmYVcpmrkCSgSH18SArFjR6Q +KyJ9VuUo1mJEUGnEakQSaOn1UAYtO8Mh4cXXD4833G0BLjiFNOL0XRUNh35pKvcT +my/HnXvRXtpzAzTtANPxIbjli/veagU+JRWhtjtfONz0wQ5Bv1zFjnM9ewxFNPPo +7Jp9WCVeUKFZcZJo8r/k7Y4d0Y1WINOPniSCNhKcD0pva3gXLcxfdnZjdMSj++ba +XlAstjw0Oyty0EXoHXCMpelMoa+DQ7KSDGKrOtm5YFAP6Ki4go1Tt2q8nmul36cZ +Zot6eoPG/qKxW+dvmSrWhQCcfd74VbhECbzXiCFLHadq85C1K5rrLM6oVr1u7K6O +jlc1aitGgZECi6fvu61QhpUvHjCegRWzMIhah9qrv4lvxFFcA+a1jwXlnwARAQAB +tEJNaWNyb3NvZnQgQ29ycG9yYXRpb24gLSBHZW5lcmFsIEdQRyBTaWduZXIgPGdw +Z3NpZ25AbWljcm9zb2Z0LmNvbT6JAjgEEwEIACIFAmVUhiwCGwMGCwkIBwMCBhUI +AgkKCwQWAgMBAh4BAheAAAoJEO5Nd5L3SBgrDc0P/0Ubx0vqD/DgyhiP0bIs8euO +iA5BQvOCiroIkhSkFbAw8rT9a/XtRTRM2l4I8c2M1ZX9i/0wWihmFUJhiVHyRxkl +ZcEFv+ieBuhvD1gPOVLZg3To8yOTrcOnHe+FuKqA6u+3xBn2AmAWeck9o0NKhtnm +5ckweos+Qj9NoxaZX8UeGFstOiTBJeyhuJjthQ+3M0BvTxEaRcLXGSXSGSgZ00ii +YSLNgOMPF+C22bXBL/erClEYkIGCctqPvyrhV/GVNnGk2ALyJqdK+BaJeGh9mBJa +ZrP3l6vFxsAI0RNCNU1s5QaFzfFzFkiUnG/aoyuwh4xmsB+uyVkR+KigPK9gfF3S +nU7AqcdhSbUA6A0DGDRkHauHM5Wtc7730LdjiNDXbYwG/yXmDYNasoszmItZzh77 +HiQxYA5dNB9r9QJS2rHV/qe+heAJ5Rub5kxcu33DGL30qG7Q9+HRTu0oSEOIUFyT +aOJJnNUiB2D4hoKKnr5U8FYOZ7KvDcG7cDvInqYtGpNfrnIf94VeB9WJY6DbDQSA +F5yHb6X8FS0x3lMT2H1l6RRyr0278kyO18VBudtlnonC+Y1UT7eqAk6WjS5CitPX +T3Hc7jCURugXrc51igKa+p67yAaybEIuVyWF6JaINKRqiUqEPVXnHELXPbBmiHW5 +1HwdbKTMzgF8bu1JI+tQmQENBFYxWIwBCADAKoZhZlJxGNGWzqV+1OG1xiQeoowK +hssGAKvd+buXCGISZJwTLXZqIcIiLP7pqdcZWtE9bSc7yBY2MalDp9Liu0KekywQ +6VVX1T72NPf5Ev6x6DLV7aVWsCzUAF+eb7DC9fPuFLEdxmOEYoPjzrQ7cCnSV4JQ +xAqhU4T6OjbvRazGl3agOeizPXmRljMtUUttHQZnRhtlzkmwIrUivbfFPD+fEoHJ +1+uIdfOzZX8/oKHKLe2jH632kvsNzJFlROVvGLYAk2WRcLu+RjjggixhwiB+Mu/A +8Tf4V6b+YppS44q8EvVrM+QvY7LNSOffSO6Slsy9oisGTdfE39nC7pVRABEBAAG0 +N01pY3Jvc29mdCAoUmVsZWFzZSBzaWduaW5nKSA8Z3Bnc2VjdXJpdHlAbWljcm9z +b2Z0LmNvbT6JATQEEwEIAB4FAlYxWIwCGwMGCwkIBwMCAxUIAwMWAgECHgECF4AA +CgkQ6z6Urb4SKc+P9gf/diY2900wvWEgV7iMgrtGzx79W/PbwWiOkKoD9sdzhARX +WiP8Q5teL/t5TUH6TZ3BENboDjwr705jLLPwuEDtPI9jz4kvdT86JwwG6N8gnWM8 +Ldi56SdJEtXrzwtlB/Fe6tyfMT1E/PrJfgALUG9MWTIJkc0GhRJoyPpGZ6YWSLGX +nk4c0HltYKDFR7q4wtI84cBu4mjZHZbxIO6r8Cci+xxuJkpOTIpr4pdpQKpECM6x +5SaT2gVnscbN0PE19KK9nPsBxyK4wW0AvAhed2qldBPTipgzPhqB2gu0jSryil95 +bKrSmlYJd1Y1XfNHno5Dxfn5JwgySBIdWWvtOI05gw== +=iQlr +-----END PGP PUBLIC KEY BLOCK----- diff --git a/docker/airflow-base/scripts/docker/keys/postgres.asc b/docker/airflow-base/scripts/docker/keys/postgres.asc new file mode 100644 index 00000000..8480576e --- /dev/null +++ b/docker/airflow-base/scripts/docker/keys/postgres.asc @@ -0,0 +1,77 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBE6XR8IBEACVdDKT2HEH1IyHzXkb4nIWAY7echjRxo7MTcj4vbXAyBKOfjja +UrBEJWHN6fjKJXOYWXHLIYg0hOGeW9qcSiaa1/rYIbOzjfGfhE4x0Y+NJHS1db0V +G6GUj3qXaeyqIJGS2z7m0Thy4Lgr/LpZlZ78Nf1fliSzBlMo1sV7PpP/7zUO+aA4 +bKa8Rio3weMXQOZgclzgeSdqtwKnyKTQdXY5MkH1QXyFIk1nTfWwyqpJjHlgtwMi +c2cxjqG5nnV9rIYlTTjYG6RBglq0SmzF/raBnF4Lwjxq4qRqvRllBXdFu5+2pMfC +IZ10HPRdqDCTN60DUix+BTzBUT30NzaLhZbOMT5RvQtvTVgWpeIn20i2NrPWNCUh +hj490dKDLpK/v+A5/i8zPvN4c6MkDHi1FZfaoz3863dylUBR3Ip26oM0hHXf4/2U +A/oA4pCl2W0hc4aNtozjKHkVjRx5Q8/hVYu+39csFWxo6YSB/KgIEw+0W8DiTII3 +RQj/OlD68ZDmGLyQPiJvaEtY9fDrcSpI0Esm0i4sjkNbuuh0Cvwwwqo5EF1zfkVj +Tqz2REYQGMJGc5LUbIpk5sMHo1HWV038TWxlDRwtOdzw08zQA6BeWe9FOokRPeR2 +AqhyaJJwOZJodKZ76S+LDwFkTLzEKnYPCzkoRwLrEdNt1M7wQBThnC5z6wARAQAB +tBxQb3N0Z3JlU1FMIERlYmlhbiBSZXBvc2l0b3J5iQJOBBMBCAA4AhsDBQsJCAcD +BRUKCQgLBRYCAwEAAh4BAheAFiEEuXsK/KoaR/BE8kSgf8x9RqzMTPgFAlhtCD8A +CgkQf8x9RqzMTPgECxAAk8uL+dwveTv6eH21tIHcltt8U3Ofajdo+D/ayO53LiYO +xi27kdHD0zvFMUWXLGxQtWyeqqDRvDagfWglHucIcaLxoxNwL8+e+9hVFIEskQAY +kVToBCKMXTQDLarz8/J030Pmcv3ihbwB+jhnykMuyyNmht4kq0CNgnlcMCdVz0d3 +z/09puryIHJrD+A8y3TD4RM74snQuwc9u5bsckvRtRJKbP3GX5JaFZAqUyZNRJRJ +Tn2OQRBhCpxhlZ2afkAPFIq2aVnEt/Ie6tmeRCzsW3lOxEH2K7MQSfSu/kRz7ELf +Cz3NJHj7rMzC+76Rhsas60t9CjmvMuGONEpctijDWONLCuch3Pdj6XpC+MVxpgBy +2VUdkunb48YhXNW0jgFGM/BFRj+dMQOUbY8PjJjsmVV0joDruWATQG/M4C7O8iU0 +B7o6yVv4m8LDEN9CiR6r7H17m4xZseT3f+0QpMe7iQjz6XxTUFRQxXqzmNnloA1T +7VjwPqIIzkj/u0V8nICG/ktLzp1OsCFatWXh7LbU+hwYl6gsFH/mFDqVxJ3+DKQi +vyf1NatzEwl62foVjGUSpvh3ymtmtUQ4JUkNDsXiRBWczaiGSuzD9Qi0ONdkAX3b +ewqmN4TfE+XIpCPxxHXwGq9Rv1IFjOdCX0iG436GHyTLC1tTUIKF5xV4Y0+cXIOI +RgQQEQgABgUCTpdI7gAKCRDFr3dKWFELWqaPAKD1TtT5c3sZz92Fj97KYmqbNQZP ++ACfSC6+hfvlj4GxmUjp1aepoVTo3weJAhwEEAEIAAYFAk6XSQsACgkQTFprqxLS +p64F8Q//cCcutwrH50UoRFejg0EIZav6LUKejC6kpLeubbEtuaIH3r2zMblPGc4i ++eMQKo/PqyQrceRXeNNlqO6/exHozYi2meudxa6IudhwJIOn1MQykJbNMSC2sGUp +1W5M1N5EYgt4hy+qhlfnD66LR4G+9t5FscTJSy84SdiOuqgCOpQmPkVRm1HX5X1+ +dmnzMOCk5LHHQuiacV0qeGO7JcBCVEIDr+uhU1H2u5GPFNHm5u15n25tOxVivb94 +xg6NDjouECBH7cCVuW79YcExH/0X3/9G45rjdHlKPH1OIUJiiX47OTxdG3dAbB4Q +fnViRJhjehFscFvYWSqXo3pgWqUsEvv9qJac2ZEMSz9x2mj0ekWxuM6/hGWxJdB+ ++985rIelPmc7VRAXOjIxWknrXnPCZAMlPlDLu6+vZ5BhFX0Be3y38f7GNCxFkJzl +hWZ4Cj3WojMj+0DaC1eKTj3rJ7OJlt9S9xnO7OOPEUTGyzgNIDAyCiu8F4huLPaT +ape6RupxOMHZeoCVlqx3ouWctelB2oNXcxxiQ/8y+21aHfD4n/CiIFwDvIQjl7dg +mT3u5Lr6yxuosR3QJx1P6rP5ZrDTP9khT30t+HZCbvs5Pq+v/9m6XDmi+NlU7Zuh +Ehy97tL3uBDgoL4b/5BpFL5U9nruPlQzGq1P9jj40dxAaDAX/WKJAj0EEwEIACcC +GwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AFAlB5KywFCQPDFt8ACgkQf8x9RqzM +TPhuCQ//QAjRSAOCQ02qmUAikT+mTB6baOAakkYq6uHbEO7qPZkv4E/M+HPIJ4wd +nBNeSQjfvdNcZBA/x0hr5EMcBneKKPDj4hJ0panOIRQmNSTThQw9OU351gm3YQct +AMPRUu1fTJAL/AuZUQf9ESmhyVtWNlH/56HBfYjE4iVeaRkkNLJyX3vkWdJSMwC/ +LO3Lw/0M3R8itDsm74F8w4xOdSQ52nSRFRh7PunFtREl+QzQ3EA/WB4AIj3VohIG +kWDfPFCzV3cyZQiEnjAe9gG5pHsXHUWQsDFZ12t784JgkGyO5wT26pzTiuApWM3k +/9V+o3HJSgH5hn7wuTi3TelEFwP1fNzI5iUUtZdtxbFOfWMnZAypEhaLmXNkg4zD +kH44r0ss9fR0DAgUav1a25UnbOn4PgIEQy2fgHKHwRpCy20d6oCSlmgyWsR40EPP +YvtGq49A2aK6ibXmdvvFT+Ts8Z+q2SkFpoYFX20mR2nsF0fbt1lfH65P64dukxeR +GteWIeNakDD40bAAOH8+OaoTGVBJ2ACJfLVNM53PEoftavAwUYMrR910qvwYfd/4 +6rh46g1Frr9SFMKYE9uvIJIgDsQB3QBp71houU4H55M5GD8XURYs+bfiQpJG1p7e +B8e5jZx1SagNWc4XwL2FzQ9svrkbg1Y+359buUiP7T6QXX2zY++JAj0EEwEIACcC +GwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AFAlEqbZUFCQg2wEEACgkQf8x9RqzM +TPhFMQ//WxAfKMdpSIA9oIC/yPD/dJpY/+DyouOljpE6MucMy/ArBECjFTBwi/j9 +NYM4ynAk34IkhuNexc1i9/05f5RM6+riLCLgAOsADDbHD4miZzoSxiVr6GQ3YXMb +OGld9kV9Sy6mGNjcUov7iFcf5Hy5w3AjPfKuR9zXswyfzIU1YXObiiZT38l55pp/ +BSgvGVQsvbNjsff5CbEKXS7q3xW+WzN0QWF6YsfNVhFjRGj8hKtHvwKcA02wwjLe +LXVTm6915ZUKhZXUFc0vM4Pj4EgNswH8Ojw9AJaKWJIZmLyW+aP+wpu6YwVCicxB +Y59CzBO2pPJDfKFQzUtrErk9irXeuCCLesDyirxJhv8o0JAvmnMAKOLhNFUrSQ2m ++3EnF7zhfz70gHW+EG8X8mL/EN3/dUM09j6TVrjtw43RLxBzwMDeariFF9yC+5bL +tnGgxjsB9Ik6GV5v34/NEEGf1qBiAzFmDVFRZlrNDkq6gmpvGnA5hUWNr+y0i01L +jGyaLSWHYjgw2UEQOqcUtTFK9MNzbZze4mVaHMEz9/aMfX25R6qbiNqCChveIm8m +Yr5Ds2zdZx+G5bAKdzX7nx2IUAxFQJEE94VLSp3npAaTWv3sHr7dR8tSyUJ9poDw +gw4W9BIcnAM7zvFYbLF5FNggg/26njHCCN70sHt8zGxKQINMc6SJAj0EEwEIACcC +GwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AFAlLpFRkFCQ6EJy0ACgkQf8x9RqzM +TPjOZA//Zp0e25pcvle7cLc0YuFr9pBv2JIkLzPm83nkcwKmxaWayUIG4Sv6pH6h +m8+S/CHQij/yFCX+o3ngMw2J9HBUvafZ4bnbI0RGJ70GsAwraQ0VlkIfg7GUw3Tz +voGYO42rZTru9S0K/6nFP6D1HUu+U+AsJONLeb6oypQgInfXQExPZyliUnHdipei +4WR1YFW6sjSkZT/5C3J1wkAvPl5lvOVthI9Zs6bZlJLZwusKxU0UM4Btgu1Sf3nn +JcHmzisixwS9PMHE+AgPWIGSec/N27a0KmTTvImV6K6nEjXJey0K2+EYJuIBsYUN +orOGBwDFIhfRk9qGlpgt0KRyguV+AP5qvgry95IrYtrOuE7307SidEbSnvO5ezNe +mE7gT9Z1tM7IMPfmoKph4BfpNoH7aXiQh1Wo+ChdP92hZUtQrY2Nm13cmkxYjQ4Z +gMWfYMC+DA/GooSgZM5i6hYqyyfAuUD9kwRN6BqTbuAUAp+hCWYeN4D88sLYpFh3 +paDYNKJ+Gf7Yyi6gThcV956RUFDH3ys5Dk0vDL9NiWwdebWfRFbzoRM3dyGP889a +OyLzS3mh6nHzZrNGhW73kslSQek8tjKrB+56hXOnb4HaElTZGDvD5wmrrhN94kby +Gtz3cydIohvNO9d90+29h0eGEDYti7j7maHkBKUAwlcPvMg5m3Y= +=DA1T +-----END PGP PUBLIC KEY BLOCK----- diff --git a/docker/airflow-base/scripts/docker/keys/python-3.10.asc b/docker/airflow-base/scripts/docker/keys/python-3.10.asc new file mode 100644 index 00000000..e69de29b diff --git a/docker/compose.airflow.yaml b/docker/compose.airflow.yaml index 0e74b909..511e7814 100644 --- a/docker/compose.airflow.yaml +++ b/docker/compose.airflow.yaml @@ -23,8 +23,12 @@ # This configuration supports basic configuration using environment variables or an .env file # The following variables are supported: # -# AIRFLOW_VERSION - Tag of the apache/airflow base image used by Dockerfile.airflow. -# Default: 3.1.8 +# AIRFLOW_VERSION - Apache Airflow version, used to tag the locally built +# Debian Trixie base image (see `make build-airflow-base`). +# Default: 3.2.2 +# AIRFLOW_BASE_IMAGE - Trixie based Airflow base image used by Dockerfile.airflow. +# Build it first with `make build-airflow-base`. +# Default: datafeeder-airflow-base:3.2.2-trixie # AIRFLOW_UID - User ID in Airflow containers # Default: 50000 # AIRFLOW_PROJ_DIR - Base path to which all the files will be volumed. @@ -51,7 +55,8 @@ x-airflow-common: dockerfile: ./docker/Dockerfile.airflow target: development args: - AIRFLOW_VERSION: ${AIRFLOW_VERSION:-3.1.8} + AIRFLOW_VERSION: ${AIRFLOW_VERSION:-3.2.2} + AIRFLOW_BASE_IMAGE: ${AIRFLOW_BASE_IMAGE:-datafeeder-airflow-base:3.2.2-trixie} env_file: - ../.env environment: diff --git a/libs/data_manipulation/.python-version b/libs/data_manipulation/.python-version index e4fba218..24ee5b1b 100644 --- a/libs/data_manipulation/.python-version +++ b/libs/data_manipulation/.python-version @@ -1 +1 @@ -3.12 +3.13 diff --git a/libs/data_manipulation/pyproject.toml b/libs/data_manipulation/pyproject.toml index a5ce2f52..eb1955d4 100644 --- a/libs/data_manipulation/pyproject.toml +++ b/libs/data_manipulation/pyproject.toml @@ -12,7 +12,7 @@ name = "data_manipulation" version = "0.1.0" description = "Add your description here" readme = "README.md" -requires-python = "==3.12.*" +requires-python = "==3.13.*" dependencies = [ "chardet==7.4.3", diff --git a/libs/data_manipulation/src/data_manipulation/constants.py b/libs/data_manipulation/src/data_manipulation/constants.py index 9d5b33e0..09ed33d8 100644 --- a/libs/data_manipulation/src/data_manipulation/constants.py +++ b/libs/data_manipulation/src/data_manipulation/constants.py @@ -3,6 +3,12 @@ DEFAULT_GEOMETRY_COLUMN = "geom" DB_URI_PREFIX = "db://" +# OGC API Features / WFS downloads are requested as GeoJSON, which is always +# WGS84 lon/lat per RFC 7946. GDAL does not always stamp an SRID on the loaded +# geometry (it ends up as SRID 0), which then breaks downstream ST_Transform. +# We therefore assign this SRS explicitly when ingesting OGC services. +DEFAULT_OGC_SRS = "EPSG:4326" + # PostgreSQL caps identifiers at 63 chars. PostGIS auto-creates a spatial index # named `idx_
_`, so any table written via to_postgis must leave # room for that suffix or the index creation fails mid-write. diff --git a/libs/data_manipulation/src/data_manipulation/ingestion.py b/libs/data_manipulation/src/data_manipulation/ingestion.py index eb2bea15..62663f4e 100644 --- a/libs/data_manipulation/src/data_manipulation/ingestion.py +++ b/libs/data_manipulation/src/data_manipulation/ingestion.py @@ -13,7 +13,11 @@ import requests from sqlalchemy.engine import Engine -from data_manipulation.constants import DEFAULT_GEOMETRY_COLUMN, POSTGIS_TABLE_NAME_MAX_LENGTH +from data_manipulation.constants import ( + DEFAULT_GEOMETRY_COLUMN, + DEFAULT_OGC_SRS, + POSTGIS_TABLE_NAME_MAX_LENGTH, +) from data_manipulation.utils import resolve_url from data_manipulation.validators import validate_schema_name, validate_table_name @@ -196,11 +200,14 @@ def ingest_file_with_ogr2ogr( "-nln", f"{schema}.{table_name}", "-overwrite", + "-forceNullable", "-lco", f"GEOMETRY_NAME={DEFAULT_GEOMETRY_COLUMN}", "-lco", f"SCHEMA={schema}", ] + command_string = " ".join(command) + logger.debug(f"Running command: {command_string}") logger.info(f"Running ogr2ogr to ingest {file_path} into {schema}.{table_name}") @@ -317,12 +324,14 @@ def ingest_data_from_database_into_postgis( "-nln", f"{target_schema}.{target_table}", "-overwrite", + "-forceNullable", "-lco", f"GEOMETRY_NAME={DEFAULT_GEOMETRY_COLUMN}", "-lco", f"SCHEMA={target_schema}", ] - + command_string = " ".join(command) + logger.debug(f"Running command: {command_string}") # -------- # WARNING: don't log the command — both PG connection strings contain credentials # -------- @@ -431,12 +440,22 @@ def ingest_data_from_ogc_service_into_postgis( "-nln", f"{schema}.{table_name}", "-overwrite", + "-forceNullable", + "-a_srs", + DEFAULT_OGC_SRS, "-lco", f"GEOMETRY_NAME={DEFAULT_GEOMETRY_COLUMN}", + "-nlt", + "PROMOTE_TO_MULTI", + "-nlt", + "CONVERT_TO_LINEAR", "-lco", f"SCHEMA={schema}", ] + command_string = " ".join(command) + logger.debug(f"Running command: {command_string}") + if auth is not None: username, password = auth # -------- diff --git a/libs/data_manipulation/tests/test_ingestion.py b/libs/data_manipulation/tests/test_ingestion.py index 9432ed6c..2dd07892 100644 --- a/libs/data_manipulation/tests/test_ingestion.py +++ b/libs/data_manipulation/tests/test_ingestion.py @@ -73,6 +73,10 @@ def test_builds_expected_command(self, mock_run: MagicMock, engine: Engine) -> N assert "staging.places" in cmd assert "-overwrite" in cmd assert "GEOMETRY_NAME=geom" in cmd + assert "SCHEMA=staging" in cmd + # NOT NULL constraints from the source layer (e.g. WFS gml_id) must not + # be propagated, otherwise COPY fails when the value is absent. + assert "-forceNullable" in cmd @patch("data_manipulation.ingestion.subprocess.run") def test_missing_binary_raises_clean_error(self, mock_run: MagicMock, engine: Engine) -> None: @@ -126,6 +130,13 @@ def test_wfs_prefix(self, mock_run: MagicMock, engine: Engine) -> None: cmd = mock_run.call_args[0][0] assert "WFS:https://example.org/wfs" in cmd assert "ns:buildings" in cmd + # WFS layers frequently declare gml_id NOT NULL while the GeoJSON output + # leaves it empty; the constraint must be dropped on the staging table. + assert "-forceNullable" in cmd + # GeoJSON output carries no SRID, so it must be assigned explicitly, + # otherwise the staging geometry ends up as SRID 0. + assert "-a_srs" in cmd + assert "EPSG:4326" in cmd @patch("data_manipulation.ingestion.subprocess.run") def test_oapif_prefix_and_normalized_url(self, mock_run: MagicMock, engine: Engine) -> None: diff --git a/pyproject.toml b/pyproject.toml index fc56136e..f987bbd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ name = "datafeeder-python-monorepo" version = "2.0.0" description = "Datafeeder python monorepo using uv" readme = "README.md" -requires-python = "==3.12.*" +requires-python = "==3.13.*" dependencies = [] [dependency-groups] diff --git a/pyrightconfig.json b/pyrightconfig.json index d83c2314..6dcdf1eb 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -13,7 +13,7 @@ ], "venvPath": ".", "venv": ".venv", - "pythonVersion": "3.12", + "pythonVersion": "3.13", "typeCheckingMode": "strict", "reportUnknownVariableType": "warning", "reportUnknownMemberType": "warning", diff --git a/uv.lock b/uv.lock index 13f920f1..1075f791 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.12.*" +requires-python = "==3.13.*" [manifest] members = [ @@ -47,7 +47,6 @@ version = "4.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } wheels = [ @@ -56,16 +55,16 @@ wheels = [ [[package]] name = "apache-airflow-client" -version = "3.1.8" +version = "3.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/74/9ecb08b05980b1a4172f6e65cdd289dd4c691394ad8ba76e3bdfb17f4b32/apache_airflow_client-3.1.8.tar.gz", hash = "sha256:26923a954773a11940acf1d12c4592ada38ef10c3622d2fd72c094a764bdf090", size = 223960, upload-time = "2026-03-28T17:42:36.594Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/e3/adf3e1ee1464dafeaf1aa0d8b2fff7bbaf31f059bf5cbffdd366d1a4355e/apache_airflow_client-3.2.2.tar.gz", hash = "sha256:2ade7c982a57bad361654a21eb21cef931c1926baf7d30df0ca4997c63764f3c", size = 260514, upload-time = "2026-06-04T17:42:18.475Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/f6/1f9be52165f5d1e21bf89c81aa2fae07f14c8edc691e95d0780035c48c75/apache_airflow_client-3.1.8-py3-none-any.whl", hash = "sha256:ac2add19bba9bc913df7d6609d1f3657b03aeb3b88627e378614b476e4fa6712", size = 386033, upload-time = "2026-03-28T17:42:35.148Z" }, + { url = "https://files.pythonhosted.org/packages/73/de/1da8162824c7b38cd9630daa24ceccb3bd146144e1c5c49f597ad459fdc8/apache_airflow_client-3.2.2-py3-none-any.whl", hash = "sha256:4262f7c8590468be928ea53776d418cc053e6dbeeeee2dec413c2485710c95a0", size = 420252, upload-time = "2026-06-04T17:42:16.519Z" }, ] [[package]] @@ -108,18 +107,18 @@ dependencies = [ ] 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/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" }, ] [[package]] @@ -128,22 +127,22 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] @@ -191,19 +190,32 @@ version = "7.12.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/bf/638c0427c0f0d47638242e2438127f3c8ee3cfc06c7fdeb16778ed47f836/coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", size = 217704, upload-time = "2025-11-18T13:32:28.906Z" }, - { url = "https://files.pythonhosted.org/packages/08/e1/706fae6692a66c2d6b871a608bbde0da6281903fa0e9f53a39ed441da36a/coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", size = 218064, upload-time = "2025-11-18T13:32:30.161Z" }, - { url = "https://files.pythonhosted.org/packages/a9/8b/eb0231d0540f8af3ffda39720ff43cb91926489d01524e68f60e961366e4/coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", size = 249560, upload-time = "2025-11-18T13:32:31.835Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a1/67fb52af642e974d159b5b379e4d4c59d0ebe1288677fbd04bbffe665a82/coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", size = 252318, upload-time = "2025-11-18T13:32:33.178Z" }, - { url = "https://files.pythonhosted.org/packages/41/e5/38228f31b2c7665ebf9bdfdddd7a184d56450755c7e43ac721c11a4b8dab/coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", size = 253403, upload-time = "2025-11-18T13:32:34.45Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4b/df78e4c8188f9960684267c5a4897836f3f0f20a20c51606ee778a1d9749/coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", size = 249984, upload-time = "2025-11-18T13:32:35.747Z" }, - { url = "https://files.pythonhosted.org/packages/ba/51/bb163933d195a345c6f63eab9e55743413d064c291b6220df754075c2769/coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", size = 251339, upload-time = "2025-11-18T13:32:37.352Z" }, - { url = "https://files.pythonhosted.org/packages/15/40/c9b29cdb8412c837cdcbc2cfa054547dd83affe6cbbd4ce4fdb92b6ba7d1/coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", size = 249489, upload-time = "2025-11-18T13:32:39.212Z" }, - { url = "https://files.pythonhosted.org/packages/c8/da/b3131e20ba07a0de4437a50ef3b47840dfabf9293675b0cd5c2c7f66dd61/coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", size = 249070, upload-time = "2025-11-18T13:32:40.598Z" }, - { url = "https://files.pythonhosted.org/packages/70/81/b653329b5f6302c08d683ceff6785bc60a34be9ae92a5c7b63ee7ee7acec/coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", size = 250929, upload-time = "2025-11-18T13:32:42.915Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/250ac3bca9f252a5fb1338b5ad01331ebb7b40223f72bef5b1b2cb03aa64/coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", size = 220241, upload-time = "2025-11-18T13:32:44.665Z" }, - { url = "https://files.pythonhosted.org/packages/64/1c/77e79e76d37ce83302f6c21980b45e09f8aa4551965213a10e62d71ce0ab/coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", size = 221051, upload-time = "2025-11-18T13:32:46.008Z" }, - { url = "https://files.pythonhosted.org/packages/31/f5/641b8a25baae564f9e52cac0e2667b123de961985709a004e287ee7663cc/coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", size = 219692, upload-time = "2025-11-18T13:32:47.372Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/771700b4048774e48d2c54ed0c674273702713c9ee7acdfede40c2666747/coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941", size = 217725, upload-time = "2025-11-18T13:32:49.22Z" }, + { url = "https://files.pythonhosted.org/packages/17/a7/3aa4144d3bcb719bf67b22d2d51c2d577bf801498c13cb08f64173e80497/coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a", size = 218098, upload-time = "2025-11-18T13:32:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9c/b846bbc774ff81091a12a10203e70562c91ae71badda00c5ae5b613527b1/coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d", size = 249093, upload-time = "2025-11-18T13:32:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/76/b6/67d7c0e1f400b32c883e9342de4a8c2ae7c1a0b57c5de87622b7262e2309/coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211", size = 251686, upload-time = "2025-11-18T13:32:54.862Z" }, + { url = "https://files.pythonhosted.org/packages/cc/75/b095bd4b39d49c3be4bffbb3135fea18a99a431c52dd7513637c0762fecb/coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d", size = 252930, upload-time = "2025-11-18T13:32:56.417Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f3/466f63015c7c80550bead3093aacabf5380c1220a2a93c35d374cae8f762/coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c", size = 249296, upload-time = "2025-11-18T13:32:58.074Z" }, + { url = "https://files.pythonhosted.org/packages/27/86/eba2209bf2b7e28c68698fc13437519a295b2d228ba9e0ec91673e09fa92/coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9", size = 251068, upload-time = "2025-11-18T13:32:59.646Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/ca8ae7dbba962a3351f18940b359b94c6bafdd7757945fdc79ec9e452dc7/coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0", size = 249034, upload-time = "2025-11-18T13:33:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d7/39136149325cad92d420b023b5fd900dabdd1c3a0d1d5f148ef4a8cedef5/coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508", size = 248853, upload-time = "2025-11-18T13:33:02.935Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b6/76e1add8b87ef60e00643b0b7f8f7bb73d4bf5249a3be19ebefc5793dd25/coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc", size = 250619, upload-time = "2025-11-18T13:33:04.336Z" }, + { url = "https://files.pythonhosted.org/packages/95/87/924c6dc64f9203f7a3c1832a6a0eee5a8335dbe5f1bdadcc278d6f1b4d74/coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8", size = 220261, upload-time = "2025-11-18T13:33:06.493Z" }, + { url = "https://files.pythonhosted.org/packages/91/77/dd4aff9af16ff776bf355a24d87eeb48fc6acde54c907cc1ea89b14a8804/coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07", size = 221072, upload-time = "2025-11-18T13:33:07.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/49/5c9dc46205fef31b1b226a6e16513193715290584317fd4df91cdaf28b22/coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc", size = 219702, upload-time = "2025-11-18T13:33:09.631Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/f87922641c7198667994dd472a91e1d9b829c95d6c29529ceb52132436ad/coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87", size = 218420, upload-time = "2025-11-18T13:33:11.153Z" }, + { url = "https://files.pythonhosted.org/packages/85/dd/1cc13b2395ef15dbb27d7370a2509b4aee77890a464fb35d72d428f84871/coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6", size = 218773, upload-time = "2025-11-18T13:33:12.569Z" }, + { url = "https://files.pythonhosted.org/packages/74/40/35773cc4bb1e9d4658d4fb669eb4195b3151bef3bbd6f866aba5cd5dac82/coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7", size = 260078, upload-time = "2025-11-18T13:33:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ee/231bb1a6ffc2905e396557585ebc6bdc559e7c66708376d245a1f1d330fc/coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560", size = 262144, upload-time = "2025-11-18T13:33:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/28/be/32f4aa9f3bf0b56f3971001b56508352c7753915345d45fab4296a986f01/coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12", size = 264574, upload-time = "2025-11-18T13:33:17.354Z" }, + { url = "https://files.pythonhosted.org/packages/68/7c/00489fcbc2245d13ab12189b977e0cf06ff3351cb98bc6beba8bd68c5902/coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296", size = 259298, upload-time = "2025-11-18T13:33:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/96/b4/f0760d65d56c3bea95b449e02570d4abd2549dc784bf39a2d4721a2d8ceb/coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507", size = 262150, upload-time = "2025-11-18T13:33:20.644Z" }, + { url = "https://files.pythonhosted.org/packages/c5/71/9a9314df00f9326d78c1e5a910f520d599205907432d90d1c1b7a97aa4b1/coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d", size = 259763, upload-time = "2025-11-18T13:33:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/10/34/01a0aceed13fbdf925876b9a15d50862eb8845454301fe3cdd1df08b2182/coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2", size = 258653, upload-time = "2025-11-18T13:33:24.239Z" }, + { url = "https://files.pythonhosted.org/packages/8d/04/81d8fd64928acf1574bbb0181f66901c6c1c6279c8ccf5f84259d2c68ae9/coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455", size = 260856, upload-time = "2025-11-18T13:33:26.365Z" }, + { url = "https://files.pythonhosted.org/packages/f2/76/fa2a37bfaeaf1f766a2d2360a25a5297d4fb567098112f6517475eee120b/coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d", size = 220936, upload-time = "2025-11-18T13:33:28.165Z" }, + { url = "https://files.pythonhosted.org/packages/f9/52/60f64d932d555102611c366afb0eb434b34266b1d9266fc2fe18ab641c47/coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c", size = 222001, upload-time = "2025-11-18T13:33:29.656Z" }, + { url = "https://files.pythonhosted.org/packages/77/df/c303164154a5a3aea7472bf323b7c857fed93b26618ed9fc5c2955566bb0/coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d", size = 220273, upload-time = "2025-11-18T13:33:31.415Z" }, { url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" }, ] @@ -330,7 +342,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = "==1.18.4" }, - { name = "apache-airflow-client", specifier = "==3.1.8" }, + { name = "apache-airflow-client", specifier = "==3.2.2" }, { name = "data-manipulation", editable = "libs/data_manipulation" }, { name = "fastapi", extras = ["standard"], specifier = "==0.136.1" }, { name = "geojson-pydantic", specifier = "==2.1.1" }, @@ -482,22 +494,22 @@ version = "0.11.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/06/a5773706afc8bd496769786590bbc56d2d0ee419a299cc12ea3f5717fcf3/fastar-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3c51f1c2cdddbd1420d2897ace7738e36c65e17f6ae84e0bfe763f8d1068bb97", size = 708394, upload-time = "2026-04-13T17:09:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a6/d5e2a4e48495616440a21eed07558219ca90243ad00b0502586f95bd4833/fastar-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0d9d6b052baf5380baea866675dab6ccd04ec2460d12b1c46f10ce3f4ee6a820", size = 628417, upload-time = "2026-04-13T17:09:42.145Z" }, - { url = "https://files.pythonhosted.org/packages/ab/69/9816d69ac8265c9e50456637a487ccfb7a9c566efd9dbcd673df9c2558c2/fastar-0.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd2f05666d4df7e14885b5c38fefd92a785917387513d33d837ff42ec143a22f", size = 863950, upload-time = "2026-04-13T17:09:11.506Z" }, - { url = "https://files.pythonhosted.org/packages/5b/0d/f88daad53aff2e754b6b5ff2a7113f72447a34f6ef17cc23ca99988117b7/fastar-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e6e74aba1ae77ca4aedcaf1697cd413319f4c88a5ccbe5b42c709517c5097e", size = 760737, upload-time = "2026-04-13T17:07:55.958Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a6/82ef4ecd969d50d92ed3ed9dbd8fe77faa24be5e5736f716edc9f4ce8d62/fastar-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38ef77fe940bbc9b37a98bd838727f844b11731cd39358a2640ff864fb385086", size = 757603, upload-time = "2026-04-13T17:08:10.623Z" }, - { url = "https://files.pythonhosted.org/packages/03/35/50249f0d827251f8ac511495e2eacccebda80a00a0ad73e9615b8113b84f/fastar-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8955e61b32d6aff82c983217abf80933fd823b0e727586fc72f08043d996fd59", size = 923952, upload-time = "2026-04-13T17:08:25.526Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d8/faee41659e9c379d906d24eaee6d6833ac8cfef0a5df480e5c2a8d3efb33/fastar-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:483532442cdb08fbff0169510224eae0836f2f672cea6aacb52847d90fefdc46", size = 816574, upload-time = "2026-04-13T17:08:56.076Z" }, - { url = "https://files.pythonhosted.org/packages/22/47/0448ea7992b997dad2bf004bfd98eca74b5858630eae080b50c7b17d9ddc/fastar-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef5a6071121e05d8287fc75bccb054bcbac8bb0501200a0c0a8feeace5303ea4", size = 819382, upload-time = "2026-04-13T17:09:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/33/ef/0d63eb43586831b7a6f8b22c4d77125a7c594423af1f4f090fa9541b9b40/fastar-0.11.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e45e598af5afe8412197d4786efd6cf29be02e7d3d4f6a3461149eae5d7e94f1", size = 885254, upload-time = "2026-04-13T17:08:40.9Z" }, - { url = "https://files.pythonhosted.org/packages/01/25/edd584675d69e49a165052c3ee886df1c5d574f3e7d813c990306387c623/fastar-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e160919b1c47ddb8538e7e8eb4cd527281b40f0bf75110a75993838ef61f286", size = 971239, upload-time = "2026-04-13T17:10:12.997Z" }, - { url = "https://files.pythonhosted.org/packages/a5/37/e8bb24f506ba2b08fbaf36c5800e843bd4d542954e9331f00418e2d23349/fastar-0.11.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4bb4dc0fc8f7a6807febcebce8a2f3626ba4955a9263d81ecc630aad83be84c0", size = 1035185, upload-time = "2026-04-13T17:10:30.207Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bf/be753736296338149ee4cb3e92e2b5423d6ba17c7b951d15218fd7e99bbf/fastar-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4ec95af56aa173f6e320e1183001bf108ba59beaf13edd1fc8200648db203588", size = 1072191, upload-time = "2026-04-13T17:10:47.072Z" }, - { url = "https://files.pythonhosted.org/packages/d2/cd/a81c1aaafb5a22ce57c98ae22f39c89413ed53e4ee6e1b1444b0bd666a6c/fastar-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:136cf342735464091c39dc3708168f9fdeb9ebea40b1ead937c61afaf46143d9", size = 1028054, upload-time = "2026-04-13T17:11:04.293Z" }, - { url = "https://files.pythonhosted.org/packages/ec/88/1ce4eed3d70627c95f49ca017f6bbbf2ddcc4b0c601d293259de7689bc20/fastar-0.11.0-cp312-cp312-win32.whl", hash = "sha256:35f23c11b556cc4d3704587faacbc0037f7bdf6c4525cd1d09c70bda4b1c6809", size = 454198, upload-time = "2026-04-13T17:11:45.168Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1d/26ce92f4331cd61a69840db9ca6115829805eec24f285481a854f578e917/fastar-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:920bc56c3c0b8a8ca492904941d1883c1c947c858cd93343356c29122a38f44c", size = 486697, upload-time = "2026-04-13T17:11:31.084Z" }, - { url = "https://files.pythonhosted.org/packages/ed/96/e6eda4480559c69b05d466e7b5ea9170e81fef3795a73e059959a3258319/fastar-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:395248faf89e8a6bd5dc1fd544c8465113b627cb6d7c8b296796b60ebea33593", size = 462591, upload-time = "2026-04-13T17:11:20.577Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d6/3be260037e86fb694e88d47f583bac3a0188c99cee1a6b257ac26cb6b53c/fastar-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:33f544b08b4541b678e53749b4552a44720d96761fb79c172b005b1089c443ed", size = 707975, upload-time = "2026-04-13T17:09:58.866Z" }, + { url = "https://files.pythonhosted.org/packages/e1/cd/7867aefb1784662554a335f2952c75a50f0c70585ed0d2210d6cc15e5627/fastar-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c1c792447e4a642745f347ff9847c52af39633071c57ee67ed53c157fc3506", size = 628460, upload-time = "2026-04-13T17:09:43.776Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2b/d11d84bdd5e0e377771b955755771e3460b290da5809cb78c1b735ee2228/fastar-0.11.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:881247e6b6eaea59fc6569f9b61447aa6b9fc2ee864e048b4643d69c52745805", size = 863054, upload-time = "2026-04-13T17:09:13.048Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/d3f428b318fa940b1b6e785b8d54fc895dfb5d5b945ef8d5442ffa904fb2/fastar-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:863b7929845c9fec92ef6c8d59579cf46af5136655e5342f8df5cebe46cab06c", size = 760247, upload-time = "2026-04-13T17:07:57.396Z" }, + { url = "https://files.pythonhosted.org/packages/9e/04/03949aee82aabb8ede06ac5a4a5579ffaf98a8fe59ce958494508ff15513/fastar-0.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:96b4a57df12bf3211662627a3ea29d62ecb314a2434a0d0843f9fc23e47536e5", size = 756512, upload-time = "2026-04-13T17:08:12.415Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/2ca1ae0a3828ca51047962d932b80daca2522db73e8cb9d040cb6ebe28d5/fastar-0.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceef1c2c4df7b7b8ebd3f5d718bbf457b9bbdf25ce0bd07870211ec4fbd9aff4", size = 922183, upload-time = "2026-04-13T17:08:27.187Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/7fe808b1f73a68e686f25434f538c6dc10ef4dfb3db0ace22cd861744bf8/fastar-0.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8e545918441910a779659d4759ad0eef349e935fbdb4668a666d3681567eb05", size = 816394, upload-time = "2026-04-13T17:08:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/1f/17/07d086080f8a83b8d7966955e29bcdbd6a060f5bd949dc9d5abd3658cead/fastar-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28095bb8f821e85fc2764e1a55f03e5e2876dee2abe7cd0ee9420d929905d643", size = 818983, upload-time = "2026-04-13T17:09:28.46Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/2c4edf0910af2e814ff6d65b77a91196d472ca8a9fb2033bd983f6856caa/fastar-0.11.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0fafb95ecbe70f666a5e9b35dd63974ccdc9bb3d99ccdbd4014a823ec3e659b5", size = 884689, upload-time = "2026-04-13T17:08:42.763Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/04fdcbd6558e60de4ced3b55230fac47675d181252582b2fcec3c74608e5/fastar-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af48fed039b94016629dcdad1c95c90c486326dd068de2b0a4df419ee09b6821", size = 970677, upload-time = "2026-04-13T17:10:15.124Z" }, + { url = "https://files.pythonhosted.org/packages/df/b3/2b860a9658550167dbd5824c85e88d0b4b912bf493e42a6322544d6e483d/fastar-0.11.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:74cd96163f39b8638ab4e8d49708ca887959672a22871d8170d01f067319533b", size = 1034026, upload-time = "2026-04-13T17:10:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9b/fa42ea1188b144bac4b1b60753dfd449974a4d5eda132029ee7711569f94/fastar-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e8b993cb5613bab495ed482810bedc0986633fcb9a3b55c37ec88e0d6714f6a", size = 1071147, upload-time = "2026-04-13T17:10:48.833Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/d2e501556dca9f1fbc9246111a31792fb49ad908fa4927f34938a97a3604/fastar-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfe39d91fc28e37e06162d94afe01050220edb7df554acb5b702b5503e564816", size = 1028377, upload-time = "2026-04-13T17:11:06.374Z" }, + { url = "https://files.pythonhosted.org/packages/db/33/5f11f23eca0a569cd052507bc45dda2e5468697f8665728d25be44120f7d/fastar-0.11.0-cp313-cp313-win32.whl", hash = "sha256:c5f63d4d99ff4bfb37c659982ec413358bdee747005348756cc50a04d412d989", size = 454089, upload-time = "2026-04-13T17:11:46.821Z" }, + { url = "https://files.pythonhosted.org/packages/da/2f/35ff03c939cba7a255a9132367873fec6c355fd06a7f84fedcbaf4c8129f/fastar-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8690ed1928d31ded3ada308e1086525fb3871f5fa81e1b69601a3f7774004583", size = 486312, upload-time = "2026-04-13T17:11:32.86Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/ee9246cbfcbfd4144558f35e7e9a306ffe0a7564730a5188c45f21d2dab8/fastar-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:d977ded9d98a0719a305e0a4d5ee811f1d3e856d853a50acb8ae833c3cd6d5d2", size = 461975, upload-time = "2026-04-13T17:11:22.589Z" }, ] [[package]] @@ -570,14 +582,14 @@ version = "3.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, - { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, - { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, - { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, - { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, - { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, - { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, - { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, ] [[package]] @@ -608,13 +620,13 @@ version = "0.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, ] [[package]] @@ -716,24 +728,24 @@ version = "6.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, - { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, - { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, - { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, - { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, - { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, - { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, - { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, - { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, - { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, - { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, - { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, - { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, ] [[package]] @@ -766,17 +778,28 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] [[package]] @@ -891,7 +914,6 @@ name = "psycopg" version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } @@ -909,17 +931,17 @@ name = "psycopg-binary" version = "3.3.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, - { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, - { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, - { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, - { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, - { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, - { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, ] [[package]] @@ -928,17 +950,17 @@ version = "2.9.11" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, - { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, - { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, - { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, ] [[package]] @@ -979,25 +1001,21 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, ] [[package]] @@ -1059,15 +1077,24 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/ab/9893ea9fb066be70ed9074ae543914a618c131ed8dff2da1e08b3a4df4db/pyproj-3.7.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:0a9bb26a6356fb5b033433a6d1b4542158fb71e3c51de49b4c318a1dff3aeaab", size = 6219832, upload-time = "2025-08-14T12:04:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/53/78/4c64199146eed7184eb0e85bedec60a4aa8853b6ffe1ab1f3a8b962e70a0/pyproj-3.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:567caa03021178861fad27fabde87500ec6d2ee173dd32f3e2d9871e40eebd68", size = 4620650, upload-time = "2025-08-14T12:04:11.978Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ac/14a78d17943898a93ef4f8c6a9d4169911c994e3161e54a7cedeba9d8dde/pyproj-3.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c203101d1dc3c038a56cff0447acc515dd29d6e14811406ac539c21eed422b2a", size = 9667087, upload-time = "2025-08-14T12:04:13.964Z" }, - { url = "https://files.pythonhosted.org/packages/b8/be/212882c450bba74fc8d7d35cbd57e4af84792f0a56194819d98106b075af/pyproj-3.7.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1edc34266c0c23ced85f95a1ee8b47c9035eae6aca5b6b340327250e8e281630", size = 9552797, upload-time = "2025-08-14T12:04:16.624Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c0/c0f25c87b5d2a8686341c53c1792a222a480d6c9caf60311fec12c99ec26/pyproj-3.7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa9f26c21bc0e2dc3d224cb1eb4020cf23e76af179a7c66fea49b828611e4260", size = 10837036, upload-time = "2025-08-14T12:04:18.733Z" }, - { url = "https://files.pythonhosted.org/packages/5d/37/5cbd6772addde2090c91113332623a86e8c7d583eccb2ad02ea634c4a89f/pyproj-3.7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9428b318530625cb389b9ddc9c51251e172808a4af79b82809376daaeabe5e9", size = 10775952, upload-time = "2025-08-14T12:04:20.709Z" }, - { url = "https://files.pythonhosted.org/packages/69/a1/dc250e3cf83eb4b3b9a2cf86fdb5e25288bd40037ae449695550f9e96b2f/pyproj-3.7.2-cp312-cp312-win32.whl", hash = "sha256:b3d99ed57d319da042f175f4554fc7038aa4bcecc4ac89e217e350346b742c9d", size = 5898872, upload-time = "2025-08-14T12:04:22.485Z" }, - { url = "https://files.pythonhosted.org/packages/4a/a6/6fe724b72b70f2b00152d77282e14964d60ab092ec225e67c196c9b463e5/pyproj-3.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:11614a054cd86a2ed968a657d00987a86eeb91fdcbd9ad3310478685dc14a128", size = 6312176, upload-time = "2025-08-14T12:04:24.736Z" }, - { url = "https://files.pythonhosted.org/packages/5d/68/915cc32c02a91e76d02c8f55d5a138d6ef9e47a0d96d259df98f4842e558/pyproj-3.7.2-cp312-cp312-win_arm64.whl", hash = "sha256:509a146d1398bafe4f53273398c3bb0b4732535065fa995270e52a9d3676bca3", size = 6233452, upload-time = "2025-08-14T12:04:27.287Z" }, + { url = "https://files.pythonhosted.org/packages/be/14/faf1b90d267cea68d7e70662e7f88cefdb1bc890bd596c74b959e0517a72/pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1", size = 6214580, upload-time = "2025-08-14T12:04:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/da9a45b184d375f62667f62eba0ca68569b0bd980a0bb7ffcc1d50440520/pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7", size = 4615388, upload-time = "2025-08-14T12:04:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e7/d2b459a4a64bca328b712c1b544e109df88e5c800f7c143cfbc404d39bfb/pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa", size = 9628455, upload-time = "2025-08-14T12:04:32.435Z" }, + { url = "https://files.pythonhosted.org/packages/f8/85/c2b1706e51942de19076eff082f8495e57d5151364e78b5bef4af4a1d94a/pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681", size = 9514269, upload-time = "2025-08-14T12:04:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/34/38/07a9b89ae7467872f9a476883a5bad9e4f4d1219d31060f0f2b282276cbe/pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5", size = 10808437, upload-time = "2025-08-14T12:04:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/fda1daeabbd39dec5b07f67233d09f31facb762587b498e6fc4572be9837/pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67", size = 10745540, upload-time = "2025-08-14T12:04:38.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/c793182cbba65a39a11db2ac6b479fe76c59e6509ae75e5744c344a0da9d/pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3", size = 5896506, upload-time = "2025-08-14T12:04:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/be/0f/747974129cf0d800906f81cd25efd098c96509026e454d4b66868779ab04/pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7", size = 6310195, upload-time = "2025-08-14T12:04:42.974Z" }, + { url = "https://files.pythonhosted.org/packages/82/64/fc7598a53172c4931ec6edf5228280663063150625d3f6423b4c20f9daff/pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100", size = 6230748, upload-time = "2025-08-14T12:04:44.491Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f0/611dd5cddb0d277f94b7af12981f56e1441bf8d22695065d4f0df5218498/pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279", size = 6241729, upload-time = "2025-08-14T12:04:46.274Z" }, + { url = "https://files.pythonhosted.org/packages/15/93/40bd4a6c523ff9965e480870611aed7eda5aa2c6128c6537345a2b77b542/pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6", size = 4652497, upload-time = "2025-08-14T12:04:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/7150ead53c117880b35e0d37960d3138fe640a235feb9605cb9386f50bb0/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220", size = 9942610, upload-time = "2025-08-14T12:04:49.652Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/7a4a7eafecf2b46ab64e5c08176c20ceb5844b503eaa551bf12ccac77322/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c", size = 9692390, upload-time = "2025-08-14T12:04:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/c3/55/ae18f040f6410f0ea547a21ada7ef3e26e6c82befa125b303b02759c0e9d/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c", size = 11047596, upload-time = "2025-08-14T12:04:53.748Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2e/d3fff4d2909473f26ae799f9dda04caa322c417a51ff3b25763f7d03b233/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69", size = 10896975, upload-time = "2025-08-14T12:04:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bc/8fc7d3963d87057b7b51ebe68c1e7c51c23129eee5072ba6b86558544a46/pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2", size = 5953057, upload-time = "2025-08-14T12:04:58.466Z" }, + { url = "https://files.pythonhosted.org/packages/cc/27/ea9809966cc47d2d51e6d5ae631ea895f7c7c7b9b3c29718f900a8f7d197/pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3", size = 6375414, upload-time = "2025-08-14T12:04:59.861Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/1ef0129fba9a555c658e22af68989f35e7ba7b9136f25758809efec0cd6e/pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd", size = 6262501, upload-time = "2025-08-14T12:05:01.39Z" }, ] [[package]] @@ -1105,7 +1132,6 @@ version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, - { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ @@ -1161,9 +1187,9 @@ name = "pywin32" version = "311" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, ] [[package]] @@ -1172,16 +1198,16 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/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" }, ] [[package]] @@ -1191,7 +1217,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, - { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -1246,21 +1271,21 @@ version = "0.7.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" }, - { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" }, - { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" }, - { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" }, - { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" }, - { url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" }, - { url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" }, - { url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" }, - { url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" }, - { url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" }, + { url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" }, + { url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" }, + { url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" }, + { url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" }, ] [[package]] @@ -1269,21 +1294,35 @@ version = "0.30.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, ] [[package]] @@ -1362,13 +1401,19 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, + { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, + { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, + { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, ] @@ -1404,7 +1449,6 @@ version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } wheels = [ @@ -1417,13 +1461,13 @@ version = "0.25.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961, upload-time = "2025-09-25T17:37:59.751Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941, upload-time = "2025-09-25T17:37:34.813Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699, upload-time = "2025-09-25T17:37:36.349Z" }, - { url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125, upload-time = "2025-09-25T17:37:37.725Z" }, - { url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418, upload-time = "2025-09-25T17:37:38.922Z" }, - { url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250, upload-time = "2025-09-25T17:37:40.039Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156, upload-time = "2025-09-25T17:37:41.132Z" }, - { url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984, upload-time = "2025-09-25T17:37:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/67492014ce32729b63d7ef318a19f9cfedd855d677de5773476caf771e96/tree_sitter-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0628671f0de69bb279558ef6b640bcfc97864fe0026d840f872728a86cd6b6cd", size = 146926, upload-time = "2025-09-25T17:37:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/a278b15e6b263e86c5e301c82a60923fa7c59d44f78d7a110a89a413e640/tree_sitter-0.25.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f5ddcd3e291a749b62521f71fc953f66f5fd9743973fd6dd962b092773569601", size = 137712, upload-time = "2025-09-25T17:37:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/423bba15d2bf6473ba67846ba5244b988cd97a4b1ea2b146822162256794/tree_sitter-0.25.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd88fbb0f6c3a0f28f0a68d72df88e9755cf5215bae146f5a1bdc8362b772053", size = 607873, upload-time = "2025-09-25T17:37:45.477Z" }, + { url = "https://files.pythonhosted.org/packages/ed/4c/b430d2cb43f8badfb3a3fa9d6cd7c8247698187b5674008c9d67b2a90c8e/tree_sitter-0.25.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b878e296e63661c8e124177cc3084b041ba3f5936b43076d57c487822426f614", size = 636313, upload-time = "2025-09-25T17:37:46.68Z" }, + { url = "https://files.pythonhosted.org/packages/9d/27/5f97098dbba807331d666a0997662e82d066e84b17d92efab575d283822f/tree_sitter-0.25.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d77605e0d353ba3fe5627e5490f0fbfe44141bafa4478d88ef7954a61a848dae", size = 631370, upload-time = "2025-09-25T17:37:47.993Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/87caaed663fabc35e18dc704cd0e9800a0ee2f22bd18b9cbe7c10799895d/tree_sitter-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:463c032bd02052d934daa5f45d183e0521ceb783c2548501cf034b0beba92c9b", size = 127157, upload-time = "2025-09-25T17:37:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/d5/23/f8467b408b7988aff4ea40946a4bd1a2c1a73d17156a9d039bbaff1e2ceb/tree_sitter-0.25.2-cp313-cp313-win_arm64.whl", hash = "sha256:b3f63a1796886249bd22c559a5944d64d05d43f2be72961624278eff0dcc5cb8", size = 113975, upload-time = "2025-09-25T17:37:49.922Z" }, ] [[package]] @@ -1632,12 +1676,12 @@ version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, ] [[package]] @@ -1646,9 +1690,9 @@ version = "5.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/48/a86139aaeab2db0a2482676f64798d8ac4d2dbb457523f50ab37bf02ce2c/watchdog-5.0.3.tar.gz", hash = "sha256:108f42a7f0345042a854d4d0ad0834b741d421330d5f575b81cb27b883500176", size = 129556, upload-time = "2024-09-27T16:10:54.863Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/9b/8b206a928c188fdeb7b12e1c795199534cd44bdef223b8470129016009dd/watchdog-5.0.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d11b07c64f63f49876e0ab8042ae034674c8653bfcdaa8c4b32e71cfff87e8", size = 96739, upload-time = "2024-09-27T16:10:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/e1/26/129ca9cd0f8016672f37000010c2fedc0b86816e894ebdc0af9bb04a6439/watchdog-5.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:349c9488e1d85d0a58e8cb14222d2c51cbc801ce11ac3936ab4c3af986536926", size = 88708, upload-time = "2024-09-27T16:10:20.924Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b3/5e10ec32f0c429cdb55b1369066d6e83faf9985b3a53a4e37bb5c5e29aa0/watchdog-5.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:53a3f10b62c2d569e260f96e8d966463dec1a50fa4f1b22aec69e3f91025060e", size = 89309, upload-time = "2024-09-27T16:10:22.299Z" }, + { url = "https://files.pythonhosted.org/packages/54/c4/49af4ab00bcfb688e9962eace2edda07a2cf89b9699ea536da48e8585cff/watchdog-5.0.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:950f531ec6e03696a2414b6308f5c6ff9dab7821a768c9d5788b1314e9a46ca7", size = 96740, upload-time = "2024-09-27T16:10:23.677Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/b24de77cc9ae424c1687c9d4fb15aa560d7d7b28ba559aca72f781d0202b/watchdog-5.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6deb336cba5d71476caa029ceb6e88047fc1dc74b62b7c4012639c0b563906", size = 88711, upload-time = "2024-09-27T16:10:25.612Z" }, + { url = "https://files.pythonhosted.org/packages/a4/71/3f2e9fe8403386b99d788868955b3a790f7a09721501a7e1eb58f514ffaa/watchdog-5.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1021223c08ba8d2d38d71ec1704496471ffd7be42cfb26b87cd5059323a389a1", size = 89319, upload-time = "2024-09-27T16:10:26.881Z" }, { url = "https://files.pythonhosted.org/packages/60/33/7cb71c9df9a77b6927ee5f48d25e1de5562ce0fa7e0c56dcf2b0472e64a2/watchdog-5.0.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:dd021efa85970bd4824acacbb922066159d0f9e546389a4743d56919b6758b91", size = 79335, upload-time = "2024-09-27T16:10:41.512Z" }, { url = "https://files.pythonhosted.org/packages/f6/91/320bc1496cf951a3cf93a7ffd18a581f0792c304be963d943e0e608c2919/watchdog-5.0.3-py3-none-manylinux2014_armv7l.whl", hash = "sha256:78864cc8f23dbee55be34cc1494632a7ba30263951b5b2e8fc8286b95845f82c", size = 79334, upload-time = "2024-09-27T16:10:42.737Z" }, { url = "https://files.pythonhosted.org/packages/8b/2c/567c5e042ed667d3544c43d48a65cf853450a2d2a9089d9523a65f195e94/watchdog-5.0.3-py3-none-manylinux2014_i686.whl", hash = "sha256:1e9679245e3ea6498494b3028b90c7b25dbb2abe65c7d07423ecfc2d6218ff7c", size = 79333, upload-time = "2024-09-27T16:10:43.984Z" }, @@ -1670,19 +1714,29 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, ] [[package]] @@ -1691,17 +1745,17 @@ version = "15.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] From 417e2ebccfc544b3d1ae16d4b3e0825c283aa56d Mon Sep 17 00:00:00 2001 From: Florian Necas Date: Thu, 11 Jun 2026 10:22:46 +0200 Subject: [PATCH 04/24] feat: add CI --- .../workflows/build-airflow-trixie-image.yml | 41 +++++++++++++++++++ Makefile | 3 +- docker/Dockerfile.airflow | 2 +- 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/build-airflow-trixie-image.yml diff --git a/.github/workflows/build-airflow-trixie-image.yml b/.github/workflows/build-airflow-trixie-image.yml new file mode 100644 index 00000000..71f6048a --- /dev/null +++ b/.github/workflows/build-airflow-trixie-image.yml @@ -0,0 +1,41 @@ +name: build-docker-images.yml +on: + workflow_dispatch: + push: + paths: + - 'docker/airflow-base/**' + +jobs: + build-airflow: + runs-on: ubuntu-latest + name: Build & push airflow base + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Getting image tag + id: version + run: echo "VERSION=$(echo $GITHUB_REF | cut -d / -f 3)" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build airflow-base + run: | + make build-airflow-base + + - name: "Log in to docker.io" + if: github.repository == 'georchestra/datafeeder' + uses: docker/login-action@v3 + with: + username: '${{ secrets.DOCKER_HUB_USERNAME }}' + password: '${{ secrets.DOCKER_HUB_PASSWORD }}' + + - name: Push airflow + run: docker push georchestra/airflow-base:latest + + - name: Push release + if: github.ref_type == 'tag' + run: | + docker tag georchestra/airflow-base:latest georchestra/airflow-base:${{ steps.version.outputs.VERSION }} + docker push georchestra/airflow-base:${{ steps.version.outputs.VERSION }} diff --git a/Makefile b/Makefile index 376dcad5..e4042557 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ default: help # Airflow Dockerfile, since apache/airflow only ships bookworm based images). AIRFLOW_VERSION ?= 3.2.2 AIRFLOW_PYTHON_VERSION ?= 3.13.5 -AIRFLOW_BASE_IMAGE ?= datafeeder-airflow-base:$(AIRFLOW_VERSION)-trixie +AIRFLOW_BASE_IMAGE ?= georchestra/airflow-base:$(AIRFLOW_VERSION)-trixie export AIRFLOW_VERSION export AIRFLOW_BASE_IMAGE @@ -51,6 +51,7 @@ build-airflow-base: ## Build the Debian Trixie based Apache Airflow base image ( --build-arg AIRFLOW_PYTHON_VERSION=$(AIRFLOW_PYTHON_VERSION) \ -t $(AIRFLOW_BASE_IMAGE) \ docker/airflow-base; \ + docker tag $(AIRFLOW_BASE_IMAGE) georchestra/airflow-base:latest; \ else \ echo "$(AIRFLOW_BASE_IMAGE) already present, skipping (run 'docker rmi $(AIRFLOW_BASE_IMAGE)' to rebuild)."; \ fi diff --git a/docker/Dockerfile.airflow b/docker/Dockerfile.airflow index fb427d88..7ded1610 100644 --- a/docker/Dockerfile.airflow +++ b/docker/Dockerfile.airflow @@ -5,7 +5,7 @@ ARG UV_VERSION=0.9.15 # Debian Trixie based Apache Airflow image, built locally from the official # Airflow Dockerfile (docker/airflow-base) since apache/airflow only ships # bookworm based images. Build it with `make build-airflow-base`. -ARG AIRFLOW_BASE_IMAGE=datafeeder-airflow-base:${AIRFLOW_VERSION}-trixie +ARG AIRFLOW_BASE_IMAGE=georchestra/airflow-base:${AIRFLOW_VERSION} # Named stage for the uv binary so later `COPY --from=uv` works on BuildKit # versions that don't support variable expansion in `--from`. From 7b9a42cd35728b729e93f11b8621da65df7ac23d Mon Sep 17 00:00:00 2001 From: Florian Necas Date: Thu, 11 Jun 2026 10:24:15 +0200 Subject: [PATCH 05/24] feat: update CI name --- .github/workflows/build-airflow-trixie-image.yml | 2 +- docker/airflow-base/README.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-airflow-trixie-image.yml b/.github/workflows/build-airflow-trixie-image.yml index 71f6048a..8a2a37f3 100644 --- a/.github/workflows/build-airflow-trixie-image.yml +++ b/.github/workflows/build-airflow-trixie-image.yml @@ -1,4 +1,4 @@ -name: build-docker-images.yml +name: build-airflow-base-image.yml on: workflow_dispatch: push: diff --git a/docker/airflow-base/README.md b/docker/airflow-base/README.md index 239b05e0..a786bab8 100644 --- a/docker/airflow-base/README.md +++ b/docker/airflow-base/README.md @@ -12,6 +12,7 @@ base image (build arg `BASE_IMAGE`). copied from the same tag. ## Build + ```bash make build-airflow-base # equivalent to: From 1acbbef7e1fecd6d7b3b9fc2cc25b4d7e470be6d Mon Sep 17 00:00:00 2001 From: fnecas Date: Tue, 18 Aug 2026 11:50:23 +0200 Subject: [PATCH 06/24] feat: add gdal to local_executor.py and use a container to execute it --- Makefile | 7 +- .../src/api/routes/ingestion/staging.py | 29 +------ .../src/services/executors/local_executor.py | 76 ++++++++++-------- .../tests/api/routes/test_staging_api.py | 47 +---------- .../tests/services/test_local_executor.py | 30 ++++---- apps/elt/dags/task_groups/transformation.py | 1 - .../dags/tests/test_transformation_group.py | 77 ++++++------------- docker/compose.datafeeder.yaml | 19 ++++- .../contribute/local_executor.en.md | 11 +++ .../src/data_manipulation/ingestion.py | 8 ++ .../data_manipulation/tests/test_ingestion.py | 6 ++ pyproject.toml | 2 +- 12 files changed, 132 insertions(+), 181 deletions(-) diff --git a/Makefile b/Makefile index e4042557..ccf94642 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ up: build-libs ## Start all services including Airflow, GeoServer and GeoNetwork docker compose --profile airflow up -d --wait --build up-no-airflow: build-libs ## Start all services including GeoServer and GeoNetwork using Docker Compose (no Airflow, replaced with the local executor) - docker compose up -d --wait --build + docker compose --profile local-executor up -d --wait --build build-airflow-base: ## Build the Debian Trixie based Apache Airflow base image (from the official Dockerfile) @if [ -z "$$(docker images -q $(AIRFLOW_BASE_IMAGE))" ]; then \ @@ -67,9 +67,10 @@ run-backend: install-python ## Run the backend application DATAFEEDER_CONFIG="$(CURDIR)/apps/backend/datafeeder.env" sh -c \ 'uv run alembic upgrade head && uv run uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir ../../apps/backend --reload-dir ../../libs' -run-backend-with-local-task-executor: install-python ## Run the backend application +run-backend-with-local-task-executor: install-python ## Run the backend application, using datafeeder-gdal (make up-no-airflow) for ogr2ogr cd apps/backend && \ - DATAFEEDER_CONFIG="$(CURDIR)/apps/backend/datafeeder.env" BACKEND_INTERNAL_URL="http://localhost:8000" TASK_EXECUTOR=LOCAL sh -c \ + DATAFEEDER_CONFIG="$(CURDIR)/apps/backend/datafeeder.env" BACKEND_INTERNAL_URL="http://localhost:8000" TASK_EXECUTOR=LOCAL \ + DATAFEEDER_GDAL_DOCKER_EXEC_TARGET=datafeeder-gdal sh -c \ 'uv run alembic upgrade head && uv run uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir ../../apps/backend --reload-dir ../../libs' .PHONY: default help install-python fix-and-check-all-python build-libs up up-no-airflow down down-v run-backend diff --git a/apps/backend/src/api/routes/ingestion/staging.py b/apps/backend/src/api/routes/ingestion/staging.py index 261e1003..0e98d3cb 100644 --- a/apps/backend/src/api/routes/ingestion/staging.py +++ b/apps/backend/src/api/routes/ingestion/staging.py @@ -1,5 +1,5 @@ import re -from datetime import date, datetime, timezone +from datetime import datetime, timezone from typing import Any, Optional from urllib.parse import urlparse from uuid import UUID, uuid4 @@ -783,33 +783,6 @@ def dag_failure_callback( datafeeder_session.commit() -def _stringify_temporal_columns(df: pd.DataFrame) -> None: - """Stringify temporal columns in place so preview data is JSON-serializable. - - Handles datetime64 columns and object-dtype columns holding Python - date/datetime objects (e.g. Parquet date32), which is_datetime64_any_dtype - does not detect. Geometry columns are left untouched. - """ - - # datetime is a subclass of date, so isinstance(v, date) matches both plain - # dates (Parquet date32) and full timestamps; isoformat() renders both cleanly. - def _iso(value: Any) -> Any: - return value.isoformat() if isinstance(value, date) else value - - for col in df.columns: - series = df[col] - if pd.api.types.is_datetime64_any_dtype(series): - df[col] = series.astype(str) # type: ignore[misc] - elif series.dtype == "object": - # Sample the first non-null value to decide the column's real type; - # an all-null column has nothing to serialize, so we skip it. - non_null = series.dropna() # type: ignore[misc] - if not non_null.empty and isinstance(non_null.iloc[0], date): # type: ignore[misc] - # Convert per-value (not astype) so any stray nulls survive as - # None instead of becoming the string "NaT"/"None". - df[col] = series.apply(_iso) # type: ignore[misc] - - def _detect_original_projection( staging_table_name: str, engine: Any, diff --git a/apps/backend/src/services/executors/local_executor.py b/apps/backend/src/services/executors/local_executor.py index 72971430..a1ac9505 100644 --- a/apps/backend/src/services/executors/local_executor.py +++ b/apps/backend/src/services/executors/local_executor.py @@ -11,6 +11,8 @@ Run state lives in memory only and is lost on backend restart. """ +import os +import tempfile import threading import traceback from concurrent.futures import ThreadPoolExecutor @@ -21,10 +23,8 @@ import requests from data_manipulation import ( - CHUNK_SIZE, IntegrityTransformation, - read_and_transform_data, - write_data_to_postgis, + transform_staging_to_final, ) from data_manipulation.constants import DB_URI_PREFIX from data_manipulation.database import create_schema @@ -50,6 +50,13 @@ _PROCESS_DAG_ID = "process_dag" _MAX_WORKERS = 4 +# Container to `docker exec` ogr2ogr into (see docker/compose.datafeeder.yaml, service +# `datafeeder-gdal`, profile `local-executor`) since this executor runs outside of any +# container with GDAL (typically on the host, via `make run-backend-with-local-task- +# executor`). Empty disables the wrapper: ogr2ogr must then already be on PATH. +_GDAL_DOCKER_EXEC_TARGET = os.getenv("DATAFEEDER_GDAL_DOCKER_EXEC_TARGET", "") +_OGR2OGR_WRAPPER_DIR = os.path.join(tempfile.gettempdir(), "datafeeder-gdal-wrapper") + @dataclass class _RunRecord: @@ -61,12 +68,32 @@ class LocalTaskExecutor(BaseTaskExecutor): """Runs staging/process synchronously in-process instead of via Airflow.""" def __init__(self) -> None: + self._install_ogr2ogr_docker_wrapper() self._pool = ThreadPoolExecutor( max_workers=_MAX_WORKERS, thread_name_prefix="local-task-executor" ) self._registry: dict[tuple[str, str], _RunRecord] = {} self._registry_lock = threading.Lock() + def _install_ogr2ogr_docker_wrapper(self) -> None: + """Make the `ogr2ogr` calls in data_manipulation.ingestion reach the + `datafeeder-gdal` sidecar container via `docker exec` instead of a local binary. + + Written under the system temp dir (not /usr/local/bin) so it works without root/ + sudo when this executor runs as a plain host process, then prepended to PATH. + """ + if not _GDAL_DOCKER_EXEC_TARGET: + return + try: + os.makedirs(_OGR2OGR_WRAPPER_DIR, exist_ok=True) + wrapper_path = os.path.join(_OGR2OGR_WRAPPER_DIR, "ogr2ogr") + with open(wrapper_path, "w") as f: + f.write(f'#!/bin/sh\nexec docker exec "{_GDAL_DOCKER_EXEC_TARGET}" ogr2ogr "$@"\n') + os.chmod(wrapper_path, 0o755) + os.environ["PATH"] = _OGR2OGR_WRAPPER_DIR + os.pathsep + os.environ.get("PATH", "") + except OSError as e: + logger.warning(f"Failed to install ogr2ogr docker-exec wrapper: {e}") + def _set_status(self, task_id: str, run_id: str, status: TaskStatus, logs: str = "") -> None: with self._registry_lock: record = self._registry.get((task_id, run_id)) @@ -280,38 +307,23 @@ def _transform( create_schema(data_engine, target_schema) - i = 0 - total_rows = 0 - while True: - transformed_data = read_and_transform_data( - table_name=staging_table_name, - engine=data_engine, - schema=staging_schema, - config=transformation_config, - limit=CHUNK_SIZE, - offset=i * CHUNK_SIZE, - ) - if transformed_data.empty: - break - - chunk_len = len(transformed_data) - write_data_to_postgis( - data=transformed_data, - table_name=final_table_name, - engine=data_engine, - schema=target_schema, - create_id=i == 0, - if_exists="replace" if i == 0 else "append", - ) - total_rows += chunk_len - if chunk_len < CHUNK_SIZE: - break - i += 1 + # Transformation runs entirely in PostGIS (CREATE TABLE AS) — no data is + # loaded into Python memory. Mirrors apps/elt/dags/task_groups/transformation.py + # so the direct (LOCAL executor) and Airflow flows apply identical logic. + row_count = transform_staging_to_final( + staging_table=staging_table_name, + final_table=final_table_name, + engine=data_engine, + config=transformation_config, + staging_schema=staging_schema, + final_schema=target_schema, + create_id=True, + ) - if total_rows == 0: + if row_count == 0: raise ValueError("No data to write after transformation.") - logger.info(f"Successfully wrote {total_rows} rows to final table") + logger.info(f"Successfully wrote {row_count} rows to final table") logger.info(f"Dropping staging table {staging_schema}.{staging_table_name}") metadata = MetaData(schema=staging_schema) diff --git a/apps/backend/tests/api/routes/test_staging_api.py b/apps/backend/tests/api/routes/test_staging_api.py index 5b306c07..7c4282ad 100644 --- a/apps/backend/tests/api/routes/test_staging_api.py +++ b/apps/backend/tests/api/routes/test_staging_api.py @@ -1,16 +1,14 @@ """Tests for API (OGC service) source type in staging endpoints.""" -from datetime import date, datetime, timezone +from datetime import datetime, timezone from unittest.mock import MagicMock, patch from uuid import uuid4 -import pandas as pd import pytest from fastapi import HTTPException from src.api.routes.ingestion.staging import ( _process_import_source, # pyright: ignore[reportPrivateUsage] - _stringify_temporal_columns, # pyright: ignore[reportPrivateUsage] dag_failure_callback, dag_success_callback, edit_staging, @@ -530,46 +528,3 @@ def test_delete_error_does_not_abort_cleanup(self, mock_delete: MagicMock) -> No datafeeder_session, _ = self._call(link) datafeeder_session.delete.assert_called_once_with(link) - - -class TestStringifyTemporalColumns: - """Preview data must be JSON-serializable; temporal columns get stringified.""" - - def test_object_date_column_becomes_iso_strings(self) -> None: - df = pd.DataFrame({"d": [date(2024, 1, 2), date(2024, 3, 4)]}) - assert df["d"].dtype == "object" - - _stringify_temporal_columns(df) - - assert df["d"].tolist() == ["2024-01-02", "2024-03-04"] - - def test_object_datetime_column_becomes_iso_strings(self) -> None: - df = pd.DataFrame({"dt": [datetime(2024, 1, 2, 8, 30), datetime(2024, 3, 4, 9, 0)]}) - df["dt"] = df["dt"].astype(object) - - _stringify_temporal_columns(df) - - assert df["dt"].tolist() == ["2024-01-02T08:30:00", "2024-03-04T09:00:00"] - - def test_datetime64_column_becomes_strings(self) -> None: - df = pd.DataFrame({"ts": pd.to_datetime(["2024-01-02", "2024-03-04"])}) - assert pd.api.types.is_datetime64_any_dtype(df["ts"]) - - _stringify_temporal_columns(df) - - assert all(isinstance(v, str) for v in df["ts"]) - - def test_non_temporal_columns_are_left_intact(self) -> None: - df = pd.DataFrame({"name": ["a", "b"], "n": [1, 2]}) - - _stringify_temporal_columns(df) - - assert df["name"].tolist() == ["a", "b"] - assert df["n"].tolist() == [1, 2] - - def test_nulls_do_not_raise_and_are_preserved(self) -> None: - df = pd.DataFrame({"d": [None, date(2024, 1, 2)]}) - - _stringify_temporal_columns(df) - - assert df["d"].tolist() == [None, "2024-01-02"] diff --git a/apps/backend/tests/services/test_local_executor.py b/apps/backend/tests/services/test_local_executor.py index dbca05ab..e3c63d89 100644 --- a/apps/backend/tests/services/test_local_executor.py +++ b/apps/backend/tests/services/test_local_executor.py @@ -1,6 +1,5 @@ from unittest.mock import MagicMock, patch -import pandas as pd import pytest from src.core.task_executor import TaskStatus @@ -211,15 +210,13 @@ def test_trigger_staging_task_database_unknown_key_fails(self) -> None: class TestLocalTaskExecutorProcess: def test_trigger_process_task_success(self) -> None: executor = _sync_executor() - chunk = pd.DataFrame({"a": [1, 2]}) with ( patch("src.services.executors.local_executor.create_schema") as mock_create_schema, patch( - "src.services.executors.local_executor.read_and_transform_data", - return_value=chunk, - ) as mock_read, - patch("src.services.executors.local_executor.write_data_to_postgis") as mock_write, + "src.services.executors.local_executor.transform_staging_to_final", + return_value=2, + ) as mock_transform, patch("src.services.executors.local_executor.Table") as mock_table_cls, patch("src.services.executors.local_executor.data_engine"), patch("src.services.executors.local_executor.requests.post") as mock_post, @@ -235,11 +232,12 @@ def test_trigger_process_task_success(self) -> None: ) mock_create_schema.assert_called_once() - assert mock_read.call_args.kwargs["table_name"] == "stg_table" - assert mock_read.call_args.kwargs["offset"] == 0 - - assert mock_write.call_args.kwargs["create_id"] is True - assert mock_write.call_args.kwargs["if_exists"] == "replace" + # Transformation runs entirely in PostGIS (CREATE TABLE AS) — same + # canonical builder used by apps/elt/dags/task_groups/transformation.py. + assert mock_transform.call_args.kwargs["staging_table"] == "stg_table" + assert mock_transform.call_args.kwargs["final_table"] == "final_table" + assert mock_transform.call_args.kwargs["final_schema"] == "data" + assert mock_transform.call_args.kwargs["create_id"] is True # staging table dropped after a successful transform mock_table_cls.assert_called_once() @@ -254,10 +252,10 @@ def test_trigger_process_task_no_rows_fails(self) -> None: with ( patch("src.services.executors.local_executor.create_schema"), patch( - "src.services.executors.local_executor.read_and_transform_data", - return_value=pd.DataFrame(), + "src.services.executors.local_executor.transform_staging_to_final", + return_value=0, ), - patch("src.services.executors.local_executor.write_data_to_postgis") as mock_write, + patch("src.services.executors.local_executor.Table") as mock_table_cls, patch("src.services.executors.local_executor.data_engine"), patch("src.services.executors.local_executor.requests.post") as mock_post, ): @@ -268,7 +266,9 @@ def test_trigger_process_task_no_rows_fails(self) -> None: failure_callback_url="https://ko.example.com", ) - mock_write.assert_not_called() + # No rows written to the final table means the staging table must + # not be dropped either — nothing was successfully transformed. + mock_table_cls.return_value.drop.assert_not_called() mock_post.assert_called_once_with("https://ko.example.com&reason=", timeout=10) status = executor.get_task_status("process_dag", "run-p2") assert status.status == TaskStatus.FAILED diff --git a/apps/elt/dags/task_groups/transformation.py b/apps/elt/dags/task_groups/transformation.py index 04e1135e..0f78bcf3 100644 --- a/apps/elt/dags/task_groups/transformation.py +++ b/apps/elt/dags/task_groups/transformation.py @@ -7,7 +7,6 @@ from airflow.sdk import task, task_group from airflow.utils.trigger_rule import TriggerRule from data_manipulation import ( - CHUNK_SIZE, IntegrityTransformation, transform_staging_to_final, ) diff --git a/apps/elt/dags/tests/test_transformation_group.py b/apps/elt/dags/tests/test_transformation_group.py index 62a09f2c..d479815e 100644 --- a/apps/elt/dags/tests/test_transformation_group.py +++ b/apps/elt/dags/tests/test_transformation_group.py @@ -58,10 +58,8 @@ class _TriggerRule: trigger_rule_stub.TriggerRule = _TriggerRule # type: ignore[attr-defined] dm_stub = types.ModuleType("data_manipulation") - dm_stub.CHUNK_SIZE = 10000 # type: ignore[attr-defined] dm_stub.IntegrityTransformation = type("IntegrityTransformation", (), {}) # type: ignore[attr-defined] - dm_stub.read_and_transform_data = lambda *a, **kw: None # type: ignore[attr-defined] - dm_stub.write_data_to_postgis = lambda *a, **kw: None # type: ignore[attr-defined] + dm_stub.transform_staging_to_final = lambda *a, **kw: 0 # type: ignore[attr-defined] dm_db_stub = types.ModuleType("data_manipulation.database") dm_db_stub.create_schema = lambda *a, **kw: None # type: ignore[attr-defined] @@ -125,37 +123,18 @@ def _build_read_task(**factory_kwargs) -> _FakeTask: return _TASK_REGISTRY["read_transform_write_task"] -class TestReadTransformWriteChunking: - """The read/transform/write task streams the staging table in chunks.""" +class TestReadTransformWriteTransformation: + """The read/transform/write task delegates to transform_staging_to_final.""" - class _FakeFrame: - def __init__(self, n: int) -> None: - self._n = n + def _run_task(self, monkeypatch, row_count): + """Execute the task with transform_staging_to_final returning row_count.""" + calls: list[dict] = [] - @property - def empty(self) -> bool: - return self._n == 0 + def fake_transform(**kwargs): + calls.append(kwargs) + return row_count - def __len__(self) -> int: - return self._n - - def _run_task(self, monkeypatch, chunk_lengths, chunk_size=2): - """Execute the task with read_and_transform_data yielding chunk_lengths.""" - read_calls: list[dict] = [] - write_calls: list[dict] = [] - - frames = [self._FakeFrame(n) for n in chunk_lengths] - - def fake_read(**kwargs): - read_calls.append(kwargs) - return frames.pop(0) if frames else self._FakeFrame(0) - - def fake_write(**kwargs): - write_calls.append(kwargs) - - monkeypatch.setattr(_transformation, "CHUNK_SIZE", chunk_size) - monkeypatch.setattr(_transformation, "read_and_transform_data", fake_read) - monkeypatch.setattr(_transformation, "write_data_to_postgis", fake_write) + monkeypatch.setattr(_transformation, "transform_staging_to_final", fake_transform) monkeypatch.setattr(_transformation, "create_schema", lambda *a, **kw: None) monkeypatch.setattr(_transformation, "get_data_sql_engine", lambda: object()) monkeypatch.setattr(_transformation, "get_staging_schema", lambda: "staging") @@ -170,33 +149,23 @@ def fake_write(**kwargs): "ti": object(), } task.fn(**context) - return read_calls, write_calls - - def test_streams_in_chunks_with_offsets(self, monkeypatch): - """Two full chunks then a short chunk: reads paginate by offset, writes append.""" - read_calls, write_calls = self._run_task(monkeypatch, [2, 2, 1], chunk_size=2) - - assert [c["offset"] for c in read_calls] == [0, 2, 4] - assert all(c["limit"] == 2 for c in read_calls) - - assert len(write_calls) == 3 - assert write_calls[0]["if_exists"] == "replace" - assert write_calls[0]["create_id"] is True - assert all(w["if_exists"] == "append" for w in write_calls[1:]) - assert all(w["create_id"] is False for w in write_calls[1:]) + return calls - def test_short_first_chunk_stops_after_one_read(self, monkeypatch): - """A first chunk smaller than CHUNK_SIZE ends the loop without an extra query.""" - read_calls, write_calls = self._run_task(monkeypatch, [1], chunk_size=2) + def test_calls_transform_staging_to_final_with_expected_args(self, monkeypatch): + """The task passes staging/final table names and schemas straight through.""" + calls = self._run_task(monkeypatch, row_count=5) - assert len(read_calls) == 1 - assert len(write_calls) == 1 - assert write_calls[0]["if_exists"] == "replace" + assert len(calls) == 1 + assert calls[0]["staging_table"] == "staging_t" + assert calls[0]["final_table"] == "final_t" + assert calls[0]["staging_schema"] == "staging" + assert calls[0]["final_schema"] == "data" + assert calls[0]["create_id"] is True def test_empty_staging_raises(self, monkeypatch): - """An empty staging table raises and never writes.""" - with pytest.raises(Exception, match="Failed to transform and load data"): - self._run_task(monkeypatch, [0], chunk_size=2) + """A transformation yielding zero rows raises and is not swallowed.""" + with pytest.raises(Exception, match="No data to write after transformation"): + self._run_task(monkeypatch, row_count=0) class TestCleanStagingTableTriggerRule: diff --git a/docker/compose.datafeeder.yaml b/docker/compose.datafeeder.yaml index 91d81f84..4f603a42 100644 --- a/docker/compose.datafeeder.yaml +++ b/docker/compose.datafeeder.yaml @@ -36,6 +36,23 @@ services: - .envs-database-georchestra restart: always + # GDAL/ogr2ogr sidecar for the LOCAL task executor: datafeeder-backend has no GDAL + # installed, so LocalTaskExecutor reaches ogr2ogr in this container via `docker exec` + # instead (see apps/backend/src/services/executors/local_executor.py). + datafeeder-gdal: + profiles: + - local-executor + image: ghcr.io/osgeo/gdal:alpine-small-3.13.2 + container_name: datafeeder-gdal + # Host networking so ogr2ogr reaches datadb the same way the host-run backend does + # (localhost:5433, see POSTGRES_DATA_HOST/PORT in datafeeder.env) — the compose + # bridge network's `datadb` DNS alias isn't reachable from the host process. + network_mode: host + entrypoint: ["tail", "-f", "/dev/null"] + restart: unless-stopped + volumes: + - /tmp/:/tmp:rw + datadb: image: postgis/postgis restart: always @@ -50,4 +67,4 @@ services: - ./data-db-init.sql:/docker-entrypoint-initdb.d/01-data-db-init.sql volumes: - pg_data: \ No newline at end of file + pg_data: diff --git a/docs/technical_guides/contribute/local_executor.en.md b/docs/technical_guides/contribute/local_executor.en.md index a75b49f5..e93d9d41 100644 --- a/docs/technical_guides/contribute/local_executor.en.md +++ b/docs/technical_guides/contribute/local_executor.en.md @@ -29,6 +29,17 @@ the host-run backend. With `LOCAL`, ingestion runs in the backend's own process Restart `make run-backend` after changing `datafeeder.env` — settings are cached at startup and `--reload` only watches source directories, not this file. +### Ingesting files with the LOCAL executor + +`LocalTaskExecutor` calls `ogr2ogr` (via `data_manipulation.ingestion`) directly, so it needs GDAL somewhere. Since +the backend runs as a plain host process here, `make up-no-airflow` also starts a `datafeeder-gdal` sidecar +(`ghcr.io/osgeo/gdal:alpine-small-3.13.2`, `local-executor` Compose profile) for it to use, instead of requiring +GDAL to be installed on the host. + +`make run-backend-with-local-task-executor` sets `DATAFEEDER_GDAL_DOCKER_EXEC_TARGET=datafeeder-gdal`; +`LocalTaskExecutor` picks that up at startup and installs a small `ogr2ogr` wrapper (using the host's own `docker` +CLI, so no extra setup is needed) that runs `docker exec datafeeder-gdal ogr2ogr ...` instead of a local binary. + ## What it actually runs `LocalTaskExecutor` only covers the flows the backend itself triggers: diff --git a/libs/data_manipulation/src/data_manipulation/ingestion.py b/libs/data_manipulation/src/data_manipulation/ingestion.py index 62663f4e..f1d4a175 100644 --- a/libs/data_manipulation/src/data_manipulation/ingestion.py +++ b/libs/data_manipulation/src/data_manipulation/ingestion.py @@ -201,6 +201,12 @@ def ingest_file_with_ogr2ogr( f"{schema}.{table_name}", "-overwrite", "-forceNullable", + # Single geometries (e.g. a shapefile of simple Polygons) are promoted to + # their Multi* equivalent so a later chunk/feature that happens to be a + # Multi* geometry doesn't clash with the column type PostGIS inferred + # from the first rows. + "-nlt", + "PROMOTE_TO_MULTI", "-lco", f"GEOMETRY_NAME={DEFAULT_GEOMETRY_COLUMN}", "-lco", @@ -325,6 +331,8 @@ def ingest_data_from_database_into_postgis( f"{target_schema}.{target_table}", "-overwrite", "-forceNullable", + "-nlt", + "PROMOTE_TO_MULTI", "-lco", f"GEOMETRY_NAME={DEFAULT_GEOMETRY_COLUMN}", "-lco", diff --git a/libs/data_manipulation/tests/test_ingestion.py b/libs/data_manipulation/tests/test_ingestion.py index 2dd07892..56ef1166 100644 --- a/libs/data_manipulation/tests/test_ingestion.py +++ b/libs/data_manipulation/tests/test_ingestion.py @@ -77,6 +77,10 @@ def test_builds_expected_command(self, mock_run: MagicMock, engine: Engine) -> N # NOT NULL constraints from the source layer (e.g. WFS gml_id) must not # be propagated, otherwise COPY fails when the value is absent. assert "-forceNullable" in cmd + # Single geometries must be promoted to Multi* so a later feature that + # happens to be a Multi* type doesn't clash with the inferred column type. + assert "-nlt" in cmd + assert "PROMOTE_TO_MULTI" in cmd @patch("data_manipulation.ingestion.subprocess.run") def test_missing_binary_raises_clean_error(self, mock_run: MagicMock, engine: Engine) -> None: @@ -113,6 +117,8 @@ def test_streams_pg_to_pg( assert any(c.startswith("PG:") and "dbhost" in c for c in cmd) assert "public.src" in cmd assert "staging.dest" in cmd + assert "-nlt" in cmd + assert "PROMOTE_TO_MULTI" in cmd class TestIngestFromOgcService: diff --git a/pyproject.toml b/pyproject.toml index f987bbd6..8d4fc703 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dev = [ [tool.uv] # Source of truth for the uv version. Keep in sync with the UV_VERSION ARG # default in apps/backend/Dockerfile and docker/Dockerfile.airflow. -required-version = "==0.9.15" +required-version = ">=0.9.15" [tool.uv.sources] geoservercloud = { git = "https://github.com/camptocamp/python-geoservercloud.git" } From 001bda2992076004b7e63d2baf17ae4ce1e5b6f0 Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 27 Aug 2026 10:45:37 +0200 Subject: [PATCH 07/24] fix(elt): use get_records_as_dicts in process-dag-generator The pandas removal dropped the dependency from apps/elt/uv.lock but left process-dag-generator.py calling PostgresHook.get_pandas_df(). Since this module runs at scheduler parse time, the ImportError would prevent every scheduled ingestion DAG from being generated. get_records_as_dicts() was already added to utils.py as the replacement but was never wired up. Call it, and drop the now-unused get_datafeeder_pg_hook import. The test stubbed get_pandas_df, so it passed against the broken path; stub the new helper instead so it exercises the real code. --- apps/elt/dags/process-dag-generator.py | 4 ++-- apps/elt/dags/tests/test_process_dag_generator.py | 10 +--------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/elt/dags/process-dag-generator.py b/apps/elt/dags/process-dag-generator.py index ab55c7f1..b61d6c4c 100644 --- a/apps/elt/dags/process-dag-generator.py +++ b/apps/elt/dags/process-dag-generator.py @@ -5,7 +5,7 @@ from airflow import DAG from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator from callback import _dag_success_callback -from utils import get_datafeeder_pg_hook, normalize_nan +from utils import get_records_as_dicts, normalize_nan def load_scheduled_integrity_links(): @@ -27,7 +27,7 @@ def load_scheduled_integrity_links(): FROM datafeeder.integrity_link WHERE schedule NOTNULL AND schedule NOT LIKE '' """ - return get_datafeeder_pg_hook().get_pandas_df(sql).to_dict(orient="records") + return get_records_as_dicts(sql) def _build_callback_url(route: str, integrity_link_id: str, final_table_name: str) -> str: diff --git a/apps/elt/dags/tests/test_process_dag_generator.py b/apps/elt/dags/tests/test_process_dag_generator.py index 73b73f6a..e3ecdc3f 100644 --- a/apps/elt/dags/tests/test_process_dag_generator.py +++ b/apps/elt/dags/tests/test_process_dag_generator.py @@ -30,16 +30,8 @@ def _load_build_callback_url(): ]: sys.modules.setdefault(mod_name, stub) - class _FakeDF: - def to_dict(self, orient): - return [] - - class _FakeHook: - def get_pandas_df(self, sql): - return _FakeDF() - utils_stub = types.ModuleType("utils") - utils_stub.get_datafeeder_pg_hook = lambda: _FakeHook() # type: ignore[attr-defined] + utils_stub.get_records_as_dicts = lambda sql: [] # type: ignore[attr-defined] utils_stub.normalize_nan = lambda value, default: default if value is None else value # type: ignore[attr-defined] sys.modules["utils"] = utils_stub From 47de8fa72cf37c5bd46dc110b29f3afbf86b35af Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 27 Aug 2026 10:46:52 +0200 Subject: [PATCH 08/24] fix(ingestion): stop logging ogr2ogr command with credentials Three call sites joined the full ogr2ogr argv and logged it at DEBUG, directly above the comment warning that the command must never be logged. The argv embeds the PG: connection string (database password) and, for OGC services, GDAL_HTTP_USERPWD. Airflow task logs are readable from the web UI, so enabling DEBUG exposed these secrets. Remove the three logger.debug calls. The surrounding logger.info lines already identify the source and target without any credential. Add regression tests asserting no password reaches the log records for the file, database-to-database and OGC paths. --- .../src/data_manipulation/ingestion.py | 8 ---- .../data_manipulation/tests/test_ingestion.py | 48 +++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/libs/data_manipulation/src/data_manipulation/ingestion.py b/libs/data_manipulation/src/data_manipulation/ingestion.py index f1d4a175..0c403f6f 100644 --- a/libs/data_manipulation/src/data_manipulation/ingestion.py +++ b/libs/data_manipulation/src/data_manipulation/ingestion.py @@ -212,9 +212,6 @@ def ingest_file_with_ogr2ogr( "-lco", f"SCHEMA={schema}", ] - command_string = " ".join(command) - logger.debug(f"Running command: {command_string}") - logger.info(f"Running ogr2ogr to ingest {file_path} into {schema}.{table_name}") # -------- @@ -338,8 +335,6 @@ def ingest_data_from_database_into_postgis( "-lco", f"SCHEMA={target_schema}", ] - command_string = " ".join(command) - logger.debug(f"Running command: {command_string}") # -------- # WARNING: don't log the command — both PG connection strings contain credentials # -------- @@ -461,9 +456,6 @@ def ingest_data_from_ogc_service_into_postgis( f"SCHEMA={schema}", ] - command_string = " ".join(command) - logger.debug(f"Running command: {command_string}") - if auth is not None: username, password = auth # -------- diff --git a/libs/data_manipulation/tests/test_ingestion.py b/libs/data_manipulation/tests/test_ingestion.py index 56ef1166..89138cd2 100644 --- a/libs/data_manipulation/tests/test_ingestion.py +++ b/libs/data_manipulation/tests/test_ingestion.py @@ -5,6 +5,7 @@ that *would* be executed. Full integration runs in the Docker image. """ +import logging import subprocess from unittest.mock import MagicMock, patch @@ -59,6 +60,53 @@ def test_leaves_plain_root_untouched(self) -> None: assert _normalize_oapif_url("https://x/ogcapi") == "https://x/ogcapi" +class TestNoCredentialLogging: + """The ogr2ogr argv embeds PG passwords and GDAL_HTTP_USERPWD: never log it.""" + + @patch("data_manipulation.ingestion.subprocess.run") + def test_file_ingest_does_not_log_password( + self, mock_run: MagicMock, engine: Engine, caplog: pytest.LogCaptureFixture + ) -> None: + mock_run.return_value = _completed() + with caplog.at_level(logging.DEBUG, logger="data_manipulation.ingestion"): + ingest_file_with_ogr2ogr("/tmp/data.geojson", "places", engine, schema="staging") + assert "secret" not in caplog.text + + @patch("data_manipulation.ingestion.subprocess.run") + def test_db_ingest_does_not_log_passwords( + self, + mock_run: MagicMock, + engine: Engine, + source_engine: Engine, + caplog: pytest.LogCaptureFixture, + ) -> None: + mock_run.return_value = _completed() + with caplog.at_level(logging.DEBUG, logger="data_manipulation.ingestion"): + ingest_data_from_database_into_postgis( + "public", "src", source_engine, "dst", engine, "staging" + ) + assert "secret" not in caplog.text + assert "srcpass" not in caplog.text + + @patch("data_manipulation.ingestion.subprocess.run") + def test_ogc_ingest_does_not_log_auth( + self, mock_run: MagicMock, engine: Engine, caplog: pytest.LogCaptureFixture + ) -> None: + mock_run.return_value = _completed() + with caplog.at_level(logging.DEBUG, logger="data_manipulation.ingestion"): + ingest_data_from_ogc_service_into_postgis( + "wfs", + "https://example.org/wfs", + "ns:buildings", + "places", + engine, + schema="staging", + auth=("wfsuser", "wfspass"), + ) + assert "secret" not in caplog.text + assert "wfspass" not in caplog.text + + class TestIngestFileWithOgr2ogr: @patch("data_manipulation.ingestion.subprocess.run") def test_builds_expected_command(self, mock_run: MagicMock, engine: Engine) -> None: From 496f4923b419664f247a2553a5fa9f2005457ba5 Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 27 Aug 2026 10:47:19 +0200 Subject: [PATCH 09/24] fix(ingestion): remove leftover debug print of ingestion timing Library code wrote ingestion timing to stdout with print(), bypassing the configured logging and polluting worker output. Drop it along with the time.time() scaffolding and the now-unused time import. --- .../data_manipulation/src/data_manipulation/ingestion.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/libs/data_manipulation/src/data_manipulation/ingestion.py b/libs/data_manipulation/src/data_manipulation/ingestion.py index 0c403f6f..b3a94d11 100644 --- a/libs/data_manipulation/src/data_manipulation/ingestion.py +++ b/libs/data_manipulation/src/data_manipulation/ingestion.py @@ -3,7 +3,6 @@ import re import subprocess import tempfile -import time from pathlib import Path from typing import Literal from urllib.error import URLError @@ -272,16 +271,8 @@ def ingest_data_from_url_into_postgis( with open(temp_file_path, "wb") as temp_file: temp_file.write(content) - start = time.time() - ingest_file_with_ogr2ogr(str(temp_file_path), table_name, engine, schema) - # Calculate the end time and time taken - end = time.time() - length = end - start - - print("It took", length, "seconds.") - except Exception as e: logger.error(f"Error ingesting data from URL {url}: {e}") raise From 20d7064a2f23bd567a2f1c66b2049854a578e0ef Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 27 Aug 2026 10:49:48 +0200 Subject: [PATCH 10/24] fix(makefile): build the Airflow base image before `make up` The airflow profile services build FROM the locally-built base image, which is never published to a registry, so `make up` failed on a fresh checkout. The target is already guarded by a `docker images -q` check, so it is a no-op once the image exists. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ccf94642..5383d952 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ test-backend-coverage: install-python ## Run backend tests with coverage report build-libs: install-python ## Build all shared libraries uv build libs/data_manipulation -up: build-libs ## Start all services including Airflow, GeoServer and GeoNetwork using Docker Compose +up: build-libs build-airflow-base ## Start all services including Airflow, GeoServer and GeoNetwork using Docker Compose docker compose --profile airflow up -d --wait --build up-no-airflow: build-libs ## Start all services including GeoServer and GeoNetwork using Docker Compose (no Airflow, replaced with the local executor) From e5204f727354e54e4c3993d3ee14084c8d58f558 Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 27 Aug 2026 11:23:43 +0200 Subject: [PATCH 11/24] perf(ingestion): stream HTTP downloads to disk instead of buffering response.content materialised the entire response body in memory before writing it to the temp file, so a multi-GB source was fully resident in RAM. This raised peak memory above the chunked geopandas reader it replaced, contradicting the goal of handing files straight to GDAL. Stream with stream=True and iter_content into the temp file. Headers are still read before the body is consumed, so Content-Disposition filename extraction is unchanged. iter_content is used rather than copyfileobj(response.raw) because it applies Content-Encoding, whereas raw would write compressed bytes. Add tests covering the URL path, which had no coverage: one asserts the body is streamed (the fake response raises on .content), the other that Content-Disposition still names the temp file. --- .../src/data_manipulation/ingestion.py | 57 ++++++------ .../data_manipulation/tests/test_ingestion.py | 91 +++++++++++++++++++ 2 files changed, 122 insertions(+), 26 deletions(-) diff --git a/libs/data_manipulation/src/data_manipulation/ingestion.py b/libs/data_manipulation/src/data_manipulation/ingestion.py index b3a94d11..6e22f127 100644 --- a/libs/data_manipulation/src/data_manipulation/ingestion.py +++ b/libs/data_manipulation/src/data_manipulation/ingestion.py @@ -30,6 +30,8 @@ # Number of rows read and written to PostGIS per chunk. Keeps the memory footprint low # (only one chunk is held in memory / converted to WKB at a time) for large files. CHUNK_SIZE = int(os.getenv("DATAFEEDER_CHUNK_SIZE", 50000)) +# Bytes read per iteration when streaming an HTTP download to disk. +_DOWNLOAD_CHUNK_SIZE = 1024 * 1024 def _build_pg_connection_string(engine: Engine) -> str: @@ -245,33 +247,36 @@ def ingest_data_from_url_into_postgis( if parsed_url.scheme == "ftp": ingest_data_from_ftp_into_postgis(url, table_name, engine, schema, auth) else: - # Use requests for HTTP/HTTPS URLs + # Use requests for HTTP/HTTPS URLs. The response is streamed straight to + # disk: ogr2ogr needs a real file anyway, and buffering the whole body in + # memory first would defeat the point of handing the file to GDAL. resolved_url = resolve_url(url) - response = requests.get(resolved_url, auth=auth, timeout=300) - response.raise_for_status() - content = response.content - - content_disposition = response.headers.get("Content-Disposition") - filename = None - - if content_disposition: - # e.g. 'attachment; filename="report.csv"' - for part in content_disposition.split(";"): - part = part.strip() - if part.startswith("filename="): - filename = part.split("=", 1)[1].strip('"') - filename = unquote(filename) - - logger.info(f"Extracted filename from Content-Disposition: {filename}") - - with tempfile.TemporaryDirectory() as temp_dir: - temp_file_path = Path(temp_dir) / ( - filename or Path(urlparse(resolved_url).path).name - ) - with open(temp_file_path, "wb") as temp_file: - temp_file.write(content) - - ingest_file_with_ogr2ogr(str(temp_file_path), table_name, engine, schema) + with requests.get(resolved_url, auth=auth, timeout=300, stream=True) as response: + response.raise_for_status() + + # Headers are available before the body is consumed. + content_disposition = response.headers.get("Content-Disposition") + filename = None + + if content_disposition: + # e.g. 'attachment; filename="report.csv"' + for part in content_disposition.split(";"): + part = part.strip() + if part.startswith("filename="): + filename = part.split("=", 1)[1].strip('"') + filename = unquote(filename) + + logger.info(f"Extracted filename from Content-Disposition: {filename}") + + with tempfile.TemporaryDirectory() as temp_dir: + temp_file_path = Path(temp_dir) / ( + filename or Path(urlparse(resolved_url).path).name + ) + with open(temp_file_path, "wb") as temp_file: + for chunk in response.iter_content(chunk_size=_DOWNLOAD_CHUNK_SIZE): + temp_file.write(chunk) + + ingest_file_with_ogr2ogr(str(temp_file_path), table_name, engine, schema) except Exception as e: logger.error(f"Error ingesting data from URL {url}: {e}") diff --git a/libs/data_manipulation/tests/test_ingestion.py b/libs/data_manipulation/tests/test_ingestion.py index 89138cd2..3c2885bb 100644 --- a/libs/data_manipulation/tests/test_ingestion.py +++ b/libs/data_manipulation/tests/test_ingestion.py @@ -7,6 +7,8 @@ import logging import subprocess +from collections.abc import Iterator +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -19,6 +21,7 @@ ingest_data_from_database_into_postgis, ingest_data_from_ftp_into_postgis, ingest_data_from_ogc_service_into_postgis, + ingest_data_from_url_into_postgis, ingest_file_with_ogr2ogr, ) @@ -107,6 +110,94 @@ def test_ogc_ingest_does_not_log_auth( assert "wfspass" not in caplog.text +def _identity_url(url: str) -> str: + """Stand in for resolve_url (which would hit the network) in tests.""" + return url + + +class _FakeResponse: + """Minimal stand-in for a streamed ``requests`` response. + + ``content`` raises so a test fails loudly if the download ever buffers the + whole body in memory again instead of streaming it to disk. + """ + + def __init__(self, chunks: list[bytes], headers: dict[str, str] | None = None) -> None: + self._chunks = chunks + self.headers = headers or {} + self.iter_content_calls: list[int | None] = [] + + @property + def content(self) -> bytes: + raise AssertionError("response.content read: the download must be streamed") + + def raise_for_status(self) -> None: + pass + + def iter_content(self, chunk_size: int | None = None) -> Iterator[bytes]: + self.iter_content_calls.append(chunk_size) + yield from self._chunks + + def __enter__(self) -> "_FakeResponse": + return self + + def __exit__(self, *exc: object) -> bool: + return False + + +class TestUrlDownloadIsStreamed: + @patch("data_manipulation.ingestion.ingest_file_with_ogr2ogr") + @patch("data_manipulation.ingestion.resolve_url", side_effect=_identity_url) + @patch("data_manipulation.ingestion.requests.get") + def test_streams_body_to_disk_without_buffering( + self, + mock_get: MagicMock, + _resolve: MagicMock, + mock_ingest: MagicMock, + engine: Engine, + ) -> None: + response = _FakeResponse([b"abc", b"def"]) + mock_get.return_value = response + + written: dict[str, bytes] = {} + + def _capture(path: str, *args: object, **kwargs: object) -> None: + written["data"] = Path(path).read_bytes() + + mock_ingest.side_effect = _capture + + ingest_data_from_url_into_postgis( + "https://example.org/data.geojson", "places", engine, schema="staging" + ) + + # stream=True is what keeps requests from materialising the whole body. + assert mock_get.call_args.kwargs["stream"] is True + assert response.iter_content_calls, "body was not streamed via iter_content" + # Chunks are reassembled verbatim on disk. + assert written["data"] == b"abcdef" + + @patch("data_manipulation.ingestion.ingest_file_with_ogr2ogr") + @patch("data_manipulation.ingestion.resolve_url", side_effect=_identity_url) + @patch("data_manipulation.ingestion.requests.get") + def test_content_disposition_still_names_the_file( + self, + mock_get: MagicMock, + _resolve: MagicMock, + mock_ingest: MagicMock, + engine: Engine, + ) -> None: + # Headers must remain readable before the body is consumed. + mock_get.return_value = _FakeResponse( + [b"x"], headers={"Content-Disposition": 'attachment; filename="report.geojson"'} + ) + + ingest_data_from_url_into_postgis( + "https://example.org/download?id=7", "places", engine, schema="staging" + ) + + assert Path(mock_ingest.call_args[0][0]).name == "report.geojson" + + class TestIngestFileWithOgr2ogr: @patch("data_manipulation.ingestion.subprocess.run") def test_builds_expected_command(self, mock_run: MagicMock, engine: Engine) -> None: From f38eadb545650546dc69d2bc8c461f8a4882ea86 Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 27 Aug 2026 16:10:27 +0200 Subject: [PATCH 12/24] fix(ingestion): open ZIP archives through GDAL's /vsizip/ filesystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zipped shapefiles were passed to ogr2ogr as a plain path, which GDAL cannot open: ogr.Open("x.zip") fails with "not recognized as being in a supported file format". Since main relied on fiona (which applies zip:// itself), handing the raw path to the ogr2ogr binary regressed a common input format. Resolve ZIPs to /vsizip/, appending the subdirectory when the dataset is not at the archive root — /vsizip/ alone fails for a nested layout. Reject archives holding more than one dataset. ogr2ogr -nln writes every layer into the same table, so with -overwrite each layer replaced the previous one and only the last survived, with a zero exit code and nothing in the logs. The error names the datasets found so the user knows which to extract. Shapefile sidecars (.shx/.dbf/.prj/...) are grouped with their .shp so they don't count as separate datasets. Verified against GDAL 3.12 for flat and nested archives; GeoPackage is unaffected. --- .../src/data_manipulation/ingestion.py | 62 +++++++++++++++ .../data_manipulation/tests/test_ingestion.py | 78 +++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/libs/data_manipulation/src/data_manipulation/ingestion.py b/libs/data_manipulation/src/data_manipulation/ingestion.py index 6e22f127..a9911043 100644 --- a/libs/data_manipulation/src/data_manipulation/ingestion.py +++ b/libs/data_manipulation/src/data_manipulation/ingestion.py @@ -3,6 +3,7 @@ import re import subprocess import tempfile +import zipfile from pathlib import Path from typing import Literal from urllib.error import URLError @@ -32,6 +33,11 @@ CHUNK_SIZE = int(os.getenv("DATAFEEDER_CHUNK_SIZE", 50000)) # Bytes read per iteration when streaming an HTTP download to disk. _DOWNLOAD_CHUNK_SIZE = 1024 * 1024 +# Shapefile sidecar files. Only the .shp names the dataset; the others accompany +# it and must not be counted as separate datasets inside an archive. +_SHAPEFILE_SIDECAR_SUFFIXES = frozenset( + {".shx", ".dbf", ".prj", ".cpg", ".sbn", ".sbx", ".qix", ".fbn", ".fbx", ".ain", ".aih"} +) def _build_pg_connection_string(engine: Engine) -> str: @@ -173,6 +179,57 @@ def ingest_data_from_ftp_into_postgis( raise +def _resolve_zip_source(file_path: str) -> str: + """Return the GDAL source path for a ZIP archive. + + GDAL cannot open a ``.zip`` by plain path — ``ogr.Open("x.zip")`` fails with + "not recognized as being in a supported file format". The archive must be + addressed through the ``/vsizip/`` virtual filesystem, and when the dataset + sits in a subdirectory that subdirectory has to be part of the path + (``/vsizip/x.zip/data``); ``/vsizip/x.zip`` alone fails just the same. + + Only single-dataset archives are supported: ``ogr2ogr ... -nln
`` + writes every layer it finds into that one table, so each layer would + silently overwrite the previous one (``-overwrite``) and only the last + would survive. Raise instead of losing data. + + Non-ZIP paths are returned unchanged. + """ + if not zipfile.is_zipfile(file_path): + return file_path + + with zipfile.ZipFile(file_path) as archive: + names = [name for name in archive.namelist() if not name.endswith("/")] + + # A shapefile is a set of sidecar files sharing one basename; every other + # supported format is a single file. Group by directory + stem so a + # shapefile counts once rather than once per extension. + datasets: dict[tuple[str, str], None] = {} + for name in names: + path = Path(name) + if path.suffix.lower() in _SHAPEFILE_SIDECAR_SUFFIXES: + continue + datasets[(str(path.parent), path.stem)] = None + + if not datasets: + raise Exception(f"No geospatial dataset found in archive {Path(file_path).name}") + + if len(datasets) > 1: + found = ", ".join(sorted(stem for _, stem in datasets)) + raise Exception( + f"Archive {Path(file_path).name} contains multiple datasets ({found}). " + "Only single-dataset archives are supported: extract the one to import " + "and upload it on its own." + ) + + (parent, _stem) = next(iter(datasets)) + source = f"/vsizip/{file_path}" + # "." is what Path(...).parent yields for a member at the archive root. + if parent not in (".", ""): + source = f"{source}/{parent}" + return source + + def ingest_file_with_ogr2ogr( file_path: str, table_name: str, @@ -181,6 +238,9 @@ def ingest_file_with_ogr2ogr( ) -> None: """Ingest a geospatial file into a PostGIS table using ogr2ogr. + ZIP archives are addressed through GDAL's ``/vsizip/`` virtual filesystem + (see :func:`_resolve_zip_source`). + Args: file_path: Path to the local file to ingest table_name: Target table name in PostGIS @@ -190,6 +250,8 @@ def ingest_file_with_ogr2ogr( validate_table_name(table_name, max_length=POSTGIS_TABLE_NAME_MAX_LENGTH) validate_schema_name(schema) + file_path = _resolve_zip_source(file_path) + pg_connection = _build_pg_connection_string(engine) command = [ diff --git a/libs/data_manipulation/tests/test_ingestion.py b/libs/data_manipulation/tests/test_ingestion.py index 3c2885bb..75b441f7 100644 --- a/libs/data_manipulation/tests/test_ingestion.py +++ b/libs/data_manipulation/tests/test_ingestion.py @@ -7,6 +7,7 @@ import logging import subprocess +import zipfile from collections.abc import Iterator from pathlib import Path from unittest.mock import MagicMock, patch @@ -18,6 +19,7 @@ from data_manipulation.ingestion import ( _build_pg_connection_string, # type: ignore[reportPrivateUsage] _normalize_oapif_url, # type: ignore[reportPrivateUsage] + _resolve_zip_source, # type: ignore[reportPrivateUsage] ingest_data_from_database_into_postgis, ingest_data_from_ftp_into_postgis, ingest_data_from_ogc_service_into_postgis, @@ -110,6 +112,82 @@ def test_ogc_ingest_does_not_log_auth( assert "wfspass" not in caplog.text +def _make_zip(path: Path, members: list[str]) -> str: + """Write a ZIP containing *members* (empty files) and return its path.""" + with zipfile.ZipFile(path, "w") as archive: + for member in members: + archive.writestr(member, b"") + return str(path) + + +_SHAPEFILE_MEMBERS = ["pts.shp", "pts.shx", "pts.dbf", "pts.prj", "pts.cpg"] + + +class TestResolveZipSource: + """GDAL cannot open a .zip by plain path; it needs the /vsizip/ prefix. + + Verified against GDAL 3.12: ogr.Open("x.zip") raises "not recognized as + being in a supported file format", while /vsizip/x.zip succeeds. + """ + + def test_plain_file_is_untouched(self, tmp_path: Path) -> None: + plain = tmp_path / "data.gpkg" + plain.write_bytes(b"not a zip") + assert _resolve_zip_source(str(plain)) == str(plain) + + def test_shapefile_at_archive_root_gets_vsizip_prefix(self, tmp_path: Path) -> None: + archive = _make_zip(tmp_path / "places.zip", _SHAPEFILE_MEMBERS) + assert _resolve_zip_source(archive) == f"/vsizip/{archive}" + + def test_shapefile_in_subdirectory_includes_that_subdirectory(self, tmp_path: Path) -> None: + # /vsizip/ alone fails here — the subdirectory must be in the path. + archive = _make_zip(tmp_path / "nested.zip", [f"data/{m}" for m in _SHAPEFILE_MEMBERS]) + assert _resolve_zip_source(archive) == f"/vsizip/{archive}/data" + + def test_sidecars_do_not_count_as_separate_datasets(self, tmp_path: Path) -> None: + # .shx/.dbf/.prj/.cpg belong to the single pts dataset. + archive = _make_zip(tmp_path / "one.zip", _SHAPEFILE_MEMBERS) + assert _resolve_zip_source(archive).startswith("/vsizip/") + + def test_single_non_shapefile_dataset_is_accepted(self, tmp_path: Path) -> None: + archive = _make_zip(tmp_path / "gpkg.zip", ["export.gpkg"]) + assert _resolve_zip_source(archive) == f"/vsizip/{archive}" + + def test_multiple_datasets_raise_instead_of_losing_data(self, tmp_path: Path) -> None: + # ogr2ogr -nln writes every layer into the same table, so with + # -overwrite each layer would silently replace the previous one. + archive = _make_zip( + tmp_path / "multi.zip", _SHAPEFILE_MEMBERS + ["second.shp", "second.dbf"] + ) + with pytest.raises(Exception, match="multiple datasets"): + _resolve_zip_source(archive) + + def test_multiple_datasets_error_names_them(self, tmp_path: Path) -> None: + archive = _make_zip( + tmp_path / "multi.zip", _SHAPEFILE_MEMBERS + ["second.shp", "second.dbf"] + ) + with pytest.raises(Exception) as excinfo: + _resolve_zip_source(archive) + # The user has to know which layer to extract. + assert "pts" in str(excinfo.value) and "second" in str(excinfo.value) + + def test_empty_archive_raises(self, tmp_path: Path) -> None: + archive = _make_zip(tmp_path / "empty.zip", []) + with pytest.raises(Exception, match="No geospatial dataset"): + _resolve_zip_source(archive) + + @patch("data_manipulation.ingestion.subprocess.run") + def test_ogr2ogr_receives_the_vsizip_path( + self, mock_run: MagicMock, engine: Engine, tmp_path: Path + ) -> None: + archive = _make_zip(tmp_path / "places.zip", _SHAPEFILE_MEMBERS) + mock_run.return_value = _completed() + + ingest_file_with_ogr2ogr(archive, "places", engine, schema="staging") + + assert f"/vsizip/{archive}" in mock_run.call_args[0][0] + + def _identity_url(url: str) -> str: """Stand in for resolve_url (which would hit the network) in tests.""" return url From d3be45a4808ed3b3a2a850ed4038529eac3d8f14 Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 27 Aug 2026 16:48:06 +0200 Subject: [PATCH 13/24] fix(ingestion): only assign EPSG:4326 for OGC API - Features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -a_srs was applied to every OGC ingestion. It relabels the CRS without reprojecting, which is correct for OAPIF (GeoJSON is WGS84 lon/lat per RFC 7946) but wrong for WFS: a WFS serves whatever srsName was negotiated, commonly a projected CRS such as EPSG:2154. Forcing 4326 on such a service tagged metric coordinates as degrees, so the downstream ST_Transform placed the data far from its real location — with no error and a zero exit code. Restrict -a_srs to the ogcFeatures protocol and let GDAL keep the SRS the WFS advertises. The WFS test asserted the old behaviour, so invert it and add the matching OAPIF case. --- .../src/data_manipulation/constants.py | 9 ++++--- .../src/data_manipulation/ingestion.py | 14 +++++++++-- .../data_manipulation/tests/test_ingestion.py | 25 ++++++++++++++++--- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/libs/data_manipulation/src/data_manipulation/constants.py b/libs/data_manipulation/src/data_manipulation/constants.py index 09ed33d8..650ad1c4 100644 --- a/libs/data_manipulation/src/data_manipulation/constants.py +++ b/libs/data_manipulation/src/data_manipulation/constants.py @@ -3,10 +3,11 @@ DEFAULT_GEOMETRY_COLUMN = "geom" DB_URI_PREFIX = "db://" -# OGC API Features / WFS downloads are requested as GeoJSON, which is always -# WGS84 lon/lat per RFC 7946. GDAL does not always stamp an SRID on the loaded -# geometry (it ends up as SRID 0), which then breaks downstream ST_Transform. -# We therefore assign this SRS explicitly when ingesting OGC services. +# OGC API - Features serves GeoJSON, which RFC 7946 pins to WGS84 lon/lat. GDAL +# does not always stamp an SRID on the loaded geometry (it ends up as SRID 0), +# which then breaks downstream ST_Transform, so we assign this SRS explicitly. +# Applies to OAPIF only: a WFS serves whatever srsName was negotiated (often a +# projected CRS), and -a_srs relabels without reprojecting. DEFAULT_OGC_SRS = "EPSG:4326" # PostgreSQL caps identifiers at 63 chars. PostGIS auto-creates a spatial index diff --git a/libs/data_manipulation/src/data_manipulation/ingestion.py b/libs/data_manipulation/src/data_manipulation/ingestion.py index a9911043..371965b2 100644 --- a/libs/data_manipulation/src/data_manipulation/ingestion.py +++ b/libs/data_manipulation/src/data_manipulation/ingestion.py @@ -502,8 +502,6 @@ def ingest_data_from_ogc_service_into_postgis( f"{schema}.{table_name}", "-overwrite", "-forceNullable", - "-a_srs", - DEFAULT_OGC_SRS, "-lco", f"GEOMETRY_NAME={DEFAULT_GEOMETRY_COLUMN}", "-nlt", @@ -514,6 +512,18 @@ def ingest_data_from_ogc_service_into_postgis( f"SCHEMA={schema}", ] + # OGC API - Features serves GeoJSON, which RFC 7946 pins to WGS84 lon/lat, so + # stamping EPSG:4326 is safe and works around GDAL leaving SRID 0 (which would + # break the downstream ST_Transform). + # + # A WFS is NOT covered by that guarantee: it serves whatever srsName was + # negotiated, commonly a projected CRS such as EPSG:2154. Since -a_srs relabels + # without reprojecting, forcing 4326 there would tag metric coordinates as + # degrees and silently place the data far from where it belongs. Let GDAL keep + # the SRS advertised by the service instead. + if protocol == "ogcFeatures": + command += ["-a_srs", DEFAULT_OGC_SRS] + if auth is not None: username, password = auth # -------- diff --git a/libs/data_manipulation/tests/test_ingestion.py b/libs/data_manipulation/tests/test_ingestion.py index 75b441f7..b3341eda 100644 --- a/libs/data_manipulation/tests/test_ingestion.py +++ b/libs/data_manipulation/tests/test_ingestion.py @@ -356,10 +356,11 @@ def test_wfs_prefix(self, mock_run: MagicMock, engine: Engine) -> None: # WFS layers frequently declare gml_id NOT NULL while the GeoJSON output # leaves it empty; the constraint must be dropped on the staging table. assert "-forceNullable" in cmd - # GeoJSON output carries no SRID, so it must be assigned explicitly, - # otherwise the staging geometry ends up as SRID 0. - assert "-a_srs" in cmd - assert "EPSG:4326" in cmd + # A WFS serves whatever srsName was negotiated — commonly a projected CRS + # such as EPSG:2154. Since -a_srs relabels without reprojecting, forcing + # 4326 here would tag metric coordinates as degrees and silently move the + # data. Keep the SRS advertised by the service. + assert "-a_srs" not in cmd @patch("data_manipulation.ingestion.subprocess.run") def test_oapif_prefix_and_normalized_url(self, mock_run: MagicMock, engine: Engine) -> None: @@ -374,6 +375,22 @@ def test_oapif_prefix_and_normalized_url(self, mock_run: MagicMock, engine: Engi cmd = mock_run.call_args[0][0] assert "OAPIF:https://example.org/ogcapi" in cmd + @patch("data_manipulation.ingestion.subprocess.run") + def test_oapif_assigns_wgs84(self, mock_run: MagicMock, engine: Engine) -> None: + # OAPIF serves GeoJSON, which RFC 7946 pins to WGS84 lon/lat, and GDAL may + # leave the geometry at SRID 0 — assigning 4326 is both safe and needed. + mock_run.return_value = _completed() + ingest_data_from_ogc_service_into_postgis( + service_url="https://example.org/ogcapi", + layer_name="buildings", + protocol="ogcFeatures", + table_name="places", + engine=engine, + ) + cmd = mock_run.call_args[0][0] + assert "-a_srs" in cmd + assert "EPSG:4326" in cmd + @patch("data_manipulation.ingestion.subprocess.run") def test_auth_passed_via_gdal_config(self, mock_run: MagicMock, engine: Engine) -> None: mock_run.return_value = _completed() From b96e30047ccc3896bd2fcca7fb19870d7a6cf695 Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 27 Aug 2026 16:50:01 +0200 Subject: [PATCH 14/24] fix(transformation): recreate the spatial index after CREATE TABLE AS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CREATE TABLE AS copies data and column types but no indexes, so the GiST index that GeoPandas' to_postgis used to create was lost. Final tables are the ones published in GeoServer, so every bbox query (WMS/WFS) degraded to a sequential scan — verified on 200k rows, where the planner switches from Seq Scan to Bitmap Heap Scan once the index exists. Recreate it as idx_
_, the name to_postgis used, keeping the behaviour iso-functional. This is also the name that POSTGIS_TABLE_NAME_MAX_LENGTH (54) is sized for: at the maximum table length the index name is exactly 63 chars, PostgreSQL's identifier cap. Only geographic results get an index; tq.geom_column is None for tabular data. --- .../transformation/sql_transform.py | 12 ++++ .../tests/test_transformation.py | 71 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/libs/data_manipulation/src/data_manipulation/transformation/sql_transform.py b/libs/data_manipulation/src/data_manipulation/transformation/sql_transform.py index 4794f7b4..e3531182 100644 --- a/libs/data_manipulation/src/data_manipulation/transformation/sql_transform.py +++ b/libs/data_manipulation/src/data_manipulation/transformation/sql_transform.py @@ -318,6 +318,18 @@ def transform_staging_to_final( # through, keeping every filter value a bound parameter. conn.exec_driver_sql(ctas, compiled.params) + # CREATE TABLE AS copies data and column types but no indexes, so the + # spatial index PostGIS used to create through GeoPandas' to_postgis is + # gone. Without it every bbox query on the published table (GeoServer + # WMS/WFS) degrades to a sequential scan. Match the previous index name. + if tq.geom_column is not None: + conn.execute( + text( + f'CREATE INDEX "idx_{final_table}_{tq.geom_column}" ' + f'ON "{final_schema}"."{final_table}" USING GIST ("{tq.geom_column}")' + ) + ) + if create_id: conn.execute( text( diff --git a/libs/data_manipulation/tests/test_transformation.py b/libs/data_manipulation/tests/test_transformation.py index 043d132d..90a3ecc6 100644 --- a/libs/data_manipulation/tests/test_transformation.py +++ b/libs/data_manipulation/tests/test_transformation.py @@ -7,6 +7,8 @@ verifying its output guarantees preview/process parity (FR-021). """ +from unittest.mock import MagicMock, patch + from sqlalchemy import Column, Integer, MetaData, Table, Text from sqlalchemy.dialects import postgresql @@ -21,6 +23,7 @@ from data_manipulation.transformation.sql_transform import ( _parse_srid, # type: ignore[reportPrivateUsage] build_transformation_select, + transform_staging_to_final, ) @@ -197,3 +200,71 @@ def test_xy_columns_build_point(self) -> None: assert tq.geom_column == "geom" assert "ST_MakePoint" in sql assert "ST_SetSRID" in sql + + +class _RecordingConnection: + """Captures the SQL transform_staging_to_final executes, without a database.""" + + def __init__(self) -> None: + self.statements: list[str] = [] + + def execute(self, statement: object, *args: object, **kwargs: object) -> MagicMock: + self.statements.append(str(statement)) + result = MagicMock() + result.scalar.return_value = 1 + return result + + def exec_driver_sql(self, statement: str, *args: object, **kwargs: object) -> MagicMock: + self.statements.append(statement) + return MagicMock() + + def commit(self) -> None: + pass + + def __enter__(self) -> "_RecordingConnection": + return self + + def __exit__(self, *exc: object) -> bool: + return False + + +def _run_transform(table: Table) -> list[str]: + """Run transform_staging_to_final against *table* and return executed SQL.""" + conn = _RecordingConnection() + engine = MagicMock() + engine.connect.return_value = conn + engine.dialect = postgresql.dialect() + + with patch("data_manipulation.transformation.sql_transform.Table", return_value=table): + transform_staging_to_final( + staging_table="places", + final_table="final_places", + engine=engine, + config=None, + staging_schema="staging", + final_schema="data", + create_id=False, + ) + return conn.statements + + +class TestSpatialIndex: + """CREATE TABLE AS copies no indexes, so the spatial index must be recreated. + + Without it every bbox query on the published table (GeoServer WMS/WFS) + degrades to a sequential scan. + """ + + def test_geographic_table_gets_gist_index(self) -> None: + sql = " ".join(_run_transform(_staging_table(with_geom=True))) + assert "CREATE INDEX" in sql + assert "USING GIST" in sql + + def test_index_name_matches_the_postgis_convention(self) -> None: + # to_postgis created idx_
_; keep that name. + sql = " ".join(_run_transform(_staging_table(with_geom=True))) + assert "idx_final_places_geom" in sql + + def test_tabular_table_gets_no_index(self) -> None: + sql = " ".join(_run_transform(_staging_table(with_geom=False))) + assert "CREATE INDEX" not in sql From a790b553974a39578fa2deb253f1c8a330f7cec3 Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 27 Aug 2026 17:43:56 +0200 Subject: [PATCH 15/24] chore: drop the chardet dependency orphaned by the ogr2ogr migration main declared chardet in data_manipulation for the geopandas encoding detection, which this branch replaces with ogr2ogr. Nothing imports it anymore, so remove the declaration. The rebase also left uv.lock referencing chardet from data-manipulation without the matching [[package]] entry, which made `uv lock` fail to parse the file. Drop those two stale references and relock. --- libs/data_manipulation/pyproject.toml | 1 - uv.lock | 2 -- 2 files changed, 3 deletions(-) diff --git a/libs/data_manipulation/pyproject.toml b/libs/data_manipulation/pyproject.toml index eb1955d4..70f04a2f 100644 --- a/libs/data_manipulation/pyproject.toml +++ b/libs/data_manipulation/pyproject.toml @@ -15,7 +15,6 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ - "chardet==7.4.3", "geoalchemy2==0.19.0", "geoservercloud", "pydantic==2.13.4", diff --git a/uv.lock b/uv.lock index 1075f791..ee33878f 100644 --- a/uv.lock +++ b/uv.lock @@ -272,7 +272,6 @@ name = "data-manipulation" version = "0.1.0" source = { editable = "libs/data_manipulation" } dependencies = [ - { name = "chardet" }, { name = "geoalchemy2" }, { name = "geoservercloud" }, { name = "pydantic" }, @@ -290,7 +289,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "chardet", specifier = "==7.4.3" }, { name = "geoalchemy2", specifier = "==0.19.0" }, { name = "geoservercloud", git = "https://github.com/camptocamp/python-geoservercloud.git" }, { name = "pydantic", specifier = "==2.13.4" }, From b46f2eec1246abcfc8e2eabe2ff79bc056f71b76 Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 27 Aug 2026 18:00:23 +0200 Subject: [PATCH 16/24] fix(ingestion): surface ogr2ogr errors instead of masking them Two ways an ogr2ogr failure went unreported: - text=True decoded stderr as strict UTF-8, but ogr2ogr echoes the offending record when it rejects non-UTF-8 input. Decoding then raised UnicodeDecodeError and hid GDAL's actual message behind a Python traceback. Decode with errors="replace". - ogr2ogr exits 0 even after aborting a layer translation ("ERROR 1: Non UTF-8 content found ... Terminating translation prematurely"), so check=True reported success while no table had been created. Scan stderr for GDAL error lines and raise. The regex is anchored at the start of a line so a path containing "error" does not match. Also chain CalledProcessError with `from exc`, which was missing. --- .../src/data_manipulation/ingestion.py | 137 +++++++++++++++++- 1 file changed, 135 insertions(+), 2 deletions(-) diff --git a/libs/data_manipulation/src/data_manipulation/ingestion.py b/libs/data_manipulation/src/data_manipulation/ingestion.py index 371965b2..a0382381 100644 --- a/libs/data_manipulation/src/data_manipulation/ingestion.py +++ b/libs/data_manipulation/src/data_manipulation/ingestion.py @@ -10,6 +10,7 @@ from urllib.parse import quote, unquote, urlencode, urlparse, urlunparse from urllib.request import urlretrieve +import chardet import requests from sqlalchemy.engine import Engine @@ -33,6 +34,13 @@ CHUNK_SIZE = int(os.getenv("DATAFEEDER_CHUNK_SIZE", 50000)) # Bytes read per iteration when streaming an HTTP download to disk. _DOWNLOAD_CHUNK_SIZE = 1024 * 1024 +# GDAL error lines, e.g. "ERROR 1: Non UTF-8 content found ...". Anchored at the +# start of a line so a path or attribute value containing "error" doesn't match. +_OGR_ERROR_RE = re.compile(r"^ERROR\s+\d+:", re.MULTILINE) +# Single-byte Latin codepages chardet confuses with CP1252 on short samples. +_WESTERN_LATIN_FALLBACK = frozenset( + {"cp1250", "windows1250", "iso88592", "iso88591", "latin1", "maccentraleurope"} +) # Shapefile sidecar files. Only the .shp names the dataset; the others accompany # it and must not be counted as separate datasets inside an archive. _SHAPEFILE_SIDECAR_SUFFIXES = frozenset( @@ -63,13 +71,26 @@ def _run_ogr2ogr(command: list[str], *, context: str) -> None: string or ``GDAL_HTTP_USERPWD`` credentials. """ try: - subprocess.run(command, check=True, capture_output=True, text=True) + # errors="replace": ogr2ogr echoes the offending record when it rejects + # non-UTF-8 input, so stderr itself may not be valid UTF-8. Strict decoding + # would raise UnicodeDecodeError here and hide GDAL's actual message. + result = subprocess.run( + command, check=True, capture_output=True, text=True, errors="replace" + ) except FileNotFoundError as exc: logger.error("ogr2ogr binary not found while %s", context) raise Exception("ogr2ogr (GDAL) is not installed or not on PATH") from exc except subprocess.CalledProcessError as exc: logger.error("ogr2ogr failed while %s: %s", context, exc.stderr) - raise Exception(f"ogr2ogr failed: {exc.stderr}") + raise Exception(f"ogr2ogr failed: {exc.stderr}") from exc + + # ogr2ogr exits 0 even when it aborts a layer translation (e.g. "ERROR 1: Non + # UTF-8 content found when writing feature"), so check=True alone would report + # success while no table was created. + stderr = result.stderr or "" + if _OGR_ERROR_RE.search(stderr): + logger.error("ogr2ogr reported an error while %s: %s", context, stderr) + raise Exception(f"ogr2ogr failed: {stderr}") def ingest_data_from_file_into_postgis( @@ -179,6 +200,110 @@ def ingest_data_from_ftp_into_postgis( raise +def _dbf_text_payload(dbf_bytes: bytes) -> bytes: + """Return the record section of a ``.dbf``, skipping its binary header. + + chardet classifies a whole ``.dbf`` as ``application/octet-stream`` and gives + up, because the fixed-width header and field descriptors drown out the few + accented bytes. Feeding it only the records makes detection work. + + Falls back to the whole buffer when the header length is implausible. + """ + # Bytes 8..10 of the DBF header hold the header length (little-endian uint16). + if len(dbf_bytes) < 12: + return dbf_bytes + header_length = int.from_bytes(dbf_bytes[8:10], "little") + if not 0 < header_length < len(dbf_bytes): + return dbf_bytes + # Drop the 0x1A end-of-file marker: chardet treats that control byte as a sign + # of binary content and gives up on an otherwise perfectly readable payload. + return dbf_bytes[header_length:].rstrip(b"\x1a") + + +def _detect_shapefile_encoding(file_path: str) -> str | None: + """Guess the encoding of a shapefile's ``.dbf``, or ``None`` when not needed. + + GDAL reads the ``.cpg`` sidecar natively and assumes UTF-8 when it is absent. + A shapefile shipped without a ``.cpg`` but encoded in e.g. CP1252 — common for + French data — then makes ogr2ogr abort with "Non UTF-8 content found". Sample + the ``.dbf`` and let chardet guess so the caller can pass SHAPE_ENCODING. + + Returns ``None`` when the source is not a shapefile, already carries a + ``.cpg``, or when nothing could be detected — in all those cases GDAL's own + handling is correct and must not be overridden. + """ + members = _shapefile_members(file_path) + if members is None: + return None + cpg_bytes, dbf_bytes = members + # A .cpg is authoritative and GDAL already honours it. + if cpg_bytes is not None or dbf_bytes is None: + return None + + try: + detected = chardet.detect(_dbf_text_payload(dbf_bytes))["encoding"] + except Exception as exc: # pragma: no cover - defensive + logger.warning("Failed to detect shapefile encoding: %s", exc) + return None + + if not detected: + return None + + normalized = detected.lower().replace("-", "").replace("_", "") + if normalized in ("utf8", "ascii"): + # ASCII is a subset of UTF-8, so GDAL's default already reads it correctly. + return None + + # On the short samples a .dbf provides, chardet routinely cannot tell the + # single-byte Latin codepages apart and returns a Central/Eastern European one + # (cp1250, iso-8859-2, ...) for Western European text — which decodes 'ê' as + # 'ę'. They agree on most of the range, so collapse them onto CP1252, the + # encoding shapefiles without a .cpg overwhelmingly use in Western Europe. + if normalized in _WESTERN_LATIN_FALLBACK: + logger.info( + "No .cpg alongside the shapefile; chardet guessed %s, using CP1252 instead", + detected, + ) + return "CP1252" + + logger.info("No .cpg alongside the shapefile; detected encoding %s", detected) + return detected + + +def _shapefile_members(file_path: str) -> tuple[bytes | None, bytes | None] | None: + """Return ``(cpg_bytes, dbf_sample)`` for a shapefile, or ``None`` if not one. + + Handles both a plain ``.shp`` on disk and a shapefile inside a ZIP, so the + encoding of zipped shapefiles can be detected without extracting them. + """ + if zipfile.is_zipfile(file_path): + with zipfile.ZipFile(file_path) as archive: + names = [name for name in archive.namelist() if not name.endswith("/")] + if not any(name.lower().endswith(".shp") for name in names): + return None + cpg = next((n for n in names if n.lower().endswith(".cpg")), None) + dbf = next((n for n in names if n.lower().endswith(".dbf")), None) + cpg_bytes = archive.read(cpg) if cpg else None + dbf_bytes = None + if dbf: + with archive.open(dbf) as handle: + dbf_bytes = handle.read(_ENCODING_DETECT_BYTES) + return cpg_bytes, dbf_bytes + + path = Path(file_path) + if path.suffix.lower() != ".shp": + return None + + cpg_path = path.with_suffix(".cpg") + dbf_path = path.with_suffix(".dbf") + cpg_bytes = cpg_path.read_bytes() if cpg_path.exists() else None + dbf_bytes = None + if dbf_path.exists(): + with open(dbf_path, "rb") as handle: + dbf_bytes = handle.read(_ENCODING_DETECT_BYTES) + return cpg_bytes, dbf_bytes + + def _resolve_zip_source(file_path: str) -> str: """Return the GDAL source path for a ZIP archive. @@ -250,6 +375,8 @@ def ingest_file_with_ogr2ogr( validate_table_name(table_name, max_length=POSTGIS_TABLE_NAME_MAX_LENGTH) validate_schema_name(schema) + # Detect before rewriting the path: the helper reads the archive itself. + shape_encoding = _detect_shapefile_encoding(file_path) file_path = _resolve_zip_source(file_path) pg_connection = _build_pg_connection_string(engine) @@ -275,6 +402,12 @@ def ingest_file_with_ogr2ogr( "-lco", f"SCHEMA={schema}", ] + + # Only set when the shapefile has no .cpg and is not UTF-8; otherwise GDAL's + # own handling (.cpg, or UTF-8 by default) is already correct. + if shape_encoding is not None: + command += ["--config", "SHAPE_ENCODING", shape_encoding] + logger.info(f"Running ogr2ogr to ingest {file_path} into {schema}.{table_name}") # -------- From 9571fcdf1ee0c5270a64ecbdb9ad21d9060575eb Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 27 Aug 2026 18:00:34 +0200 Subject: [PATCH 17/24] fix(ingestion): restore shapefile encoding detection for files without .cpg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The move to ogr2ogr dropped the chardet-based detection that main had just hardened (PR #107). GDAL covers most of it natively — it reads the .cpg sidecar and assumes UTF-8 otherwise — but a shapefile shipped without a .cpg in a Western European codepage then makes ogr2ogr abort with "Non UTF-8 content found", writing no table at all. Detect that case only: sample the .dbf (directly or inside the ZIP, as main did) and pass --config SHAPE_ENCODING. When a .cpg exists, or the content is ASCII/UTF-8, defer to GDAL and add nothing. Two details found while testing against a real PostGIS: - chardet classifies a whole .dbf as binary because the fixed-width header drowns out the text, and the trailing 0x1A EOF marker alone is enough to make it give up. Feed it the record section with 0x1A stripped. - on such short samples chardet cannot separate the Latin codepages and returns cp1250 for Western text, which decodes "ê" as "ę". Collapse those onto CP1252, what shapefiles without a .cpg overwhelmingly use here. chardet returns as a data_manipulation dependency since it is imported again. Verified end to end: shapefile and zipped shapefile, with and without .cpg, plus GeoJSON, all yield "Café"/"Forêt" in PostGIS. --- libs/data_manipulation/pyproject.toml | 1 + .../data_manipulation/tests/test_ingestion.py | 119 ++++++++++++++++++ uv.lock | 17 +++ 3 files changed, 137 insertions(+) diff --git a/libs/data_manipulation/pyproject.toml b/libs/data_manipulation/pyproject.toml index 70f04a2f..eb1955d4 100644 --- a/libs/data_manipulation/pyproject.toml +++ b/libs/data_manipulation/pyproject.toml @@ -15,6 +15,7 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ + "chardet==7.4.3", "geoalchemy2==0.19.0", "geoservercloud", "pydantic==2.13.4", diff --git a/libs/data_manipulation/tests/test_ingestion.py b/libs/data_manipulation/tests/test_ingestion.py index b3341eda..813e23ee 100644 --- a/libs/data_manipulation/tests/test_ingestion.py +++ b/libs/data_manipulation/tests/test_ingestion.py @@ -6,6 +6,7 @@ """ import logging +import struct import subprocess import zipfile from collections.abc import Iterator @@ -18,6 +19,7 @@ from data_manipulation.ingestion import ( _build_pg_connection_string, # type: ignore[reportPrivateUsage] + _detect_shapefile_encoding, # type: ignore[reportPrivateUsage] _normalize_oapif_url, # type: ignore[reportPrivateUsage] _resolve_zip_source, # type: ignore[reportPrivateUsage] ingest_data_from_database_into_postgis, @@ -438,3 +440,120 @@ def test_builds_credentialed_url( assert called_url.startswith("ftp://bob:") assert "pw%40ss" in called_url mock_ingest.assert_called_once() + + +def _dbf(records: list[bytes], *, field: bytes = b"nom", width: int = 20) -> bytes: + """Build a minimal .dbf holding one text field, for encoding-detection tests.""" + header_length = 32 + 32 + 1 + header = struct.pack(" str: + (tmp_path / "z.shp").write_bytes(b"\x00") + (tmp_path / "z.dbf").write_bytes(dbf) + if cpg is not None: + (tmp_path / "z.cpg").write_text(cpg) + return str(tmp_path / "z.shp") + + def test_cpg_present_defers_to_gdal(self, tmp_path: Path) -> None: + path = self._shapefile(tmp_path, cpg="ISO-8859-1", dbf=_dbf(_LATIN1_RECORDS)) + assert _detect_shapefile_encoding(path) is None + + def test_missing_cpg_with_latin_text_detects_cp1252(self, tmp_path: Path) -> None: + path = self._shapefile(tmp_path, cpg=None, dbf=_dbf(_LATIN1_RECORDS)) + assert _detect_shapefile_encoding(path) == "CP1252" + + def test_ascii_content_defers_to_gdal(self, tmp_path: Path) -> None: + # ASCII is valid UTF-8; overriding would be pointless. + path = self._shapefile(tmp_path, cpg=None, dbf=_dbf([b"Paris", b"Lyon"])) + assert _detect_shapefile_encoding(path) is None + + def test_non_shapefile_is_ignored(self, tmp_path: Path) -> None: + plain = tmp_path / "data.geojson" + plain.write_text('{"type":"FeatureCollection","features":[]}') + assert _detect_shapefile_encoding(str(plain)) is None + + def test_zipped_shapefile_without_cpg_is_detected(self, tmp_path: Path) -> None: + archive = tmp_path / "z.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("z.shp", b"\x00") + zf.writestr("z.dbf", _dbf(_LATIN1_RECORDS)) + assert _detect_shapefile_encoding(str(archive)) == "CP1252" + + def test_zipped_shapefile_with_cpg_defers_to_gdal(self, tmp_path: Path) -> None: + archive = tmp_path / "z.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("z.shp", b"\x00") + zf.writestr("z.dbf", _dbf(_LATIN1_RECORDS)) + zf.writestr("z.cpg", "ISO-8859-1") + assert _detect_shapefile_encoding(str(archive)) is None + + @patch("data_manipulation.ingestion.subprocess.run") + def test_shape_encoding_is_passed_to_ogr2ogr( + self, mock_run: MagicMock, engine: Engine, tmp_path: Path + ) -> None: + mock_run.return_value = _completed() + path = self._shapefile(tmp_path, cpg=None, dbf=_dbf(_LATIN1_RECORDS)) + + ingest_file_with_ogr2ogr(path, "places", engine, schema="staging") + + cmd = mock_run.call_args[0][0] + assert "SHAPE_ENCODING" in cmd + assert "CP1252" in cmd + + @patch("data_manipulation.ingestion.subprocess.run") + def test_no_shape_encoding_when_cpg_present( + self, mock_run: MagicMock, engine: Engine, tmp_path: Path + ) -> None: + mock_run.return_value = _completed() + path = self._shapefile(tmp_path, cpg="UTF-8", dbf=_dbf(_LATIN1_RECORDS)) + + ingest_file_with_ogr2ogr(path, "places", engine, schema="staging") + + assert "SHAPE_ENCODING" not in mock_run.call_args[0][0] + + +class TestOgrErrorDetection: + """ogr2ogr exits 0 even when it aborts a layer, so stderr must be inspected.""" + + @patch("data_manipulation.ingestion.subprocess.run") + def test_error_in_stderr_raises_despite_exit_zero( + self, mock_run: MagicMock, engine: Engine + ) -> None: + mock_run.return_value = subprocess.CompletedProcess( + args=["ogr2ogr"], + returncode=0, + stdout="", + stderr="ERROR 1: Non UTF-8 content found when writing feature -1\n", + ) + with pytest.raises(Exception, match="Non UTF-8 content"): + ingest_file_with_ogr2ogr("/tmp/data.geojson", "places", engine) + + @patch("data_manipulation.ingestion.subprocess.run") + def test_warnings_do_not_raise(self, mock_run: MagicMock, engine: Engine) -> None: + mock_run.return_value = subprocess.CompletedProcess( + args=["ogr2ogr"], returncode=0, stdout="", stderr="Warning 6: Normalized/laundered\n" + ) + ingest_file_with_ogr2ogr("/tmp/data.geojson", "places", engine) + + @patch("data_manipulation.ingestion.subprocess.run") + def test_error_word_in_a_path_does_not_raise(self, mock_run: MagicMock, engine: Engine) -> None: + # Anchored regex: only real "ERROR :" lines count. + mock_run.return_value = subprocess.CompletedProcess( + args=["ogr2ogr"], returncode=0, stdout="", stderr="reading /data/error_log/x.shp\n" + ) + ingest_file_with_ogr2ogr("/tmp/data.geojson", "places", engine) diff --git a/uv.lock b/uv.lock index ee33878f..c537d766 100644 --- a/uv.lock +++ b/uv.lock @@ -121,6 +121,21 @@ wheels = [ { 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" }, ] +[[package]] +name = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -272,6 +287,7 @@ name = "data-manipulation" version = "0.1.0" source = { editable = "libs/data_manipulation" } dependencies = [ + { name = "chardet" }, { name = "geoalchemy2" }, { name = "geoservercloud" }, { name = "pydantic" }, @@ -289,6 +305,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "chardet", specifier = "==7.4.3" }, { name = "geoalchemy2", specifier = "==0.19.0" }, { name = "geoservercloud", git = "https://github.com/camptocamp/python-geoservercloud.git" }, { name = "pydantic", specifier = "==2.13.4" }, From 4ea7c9c38615b12429c28769294c69431dd6d6d6 Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 3 Sep 2026 10:07:58 +0200 Subject: [PATCH 18/24] chore(ingestion): remove code left dead by the ogr2ogr migration The chunked WFS pagination that _wfs_json_output_format and _wfs_geojson_chunk_url served was replaced by a single ogr2ogr call, and CHUNK_SIZE lost its last caller when reads stopped going through pandas. _wfs_json_output_format also referenced ET, whose xml.etree.ElementTree import this branch had already removed, so calling it would have raised NameError. Ruff flagged that (F821) alongside the unused typing.Literal (F401), which together were failing lint on the whole branch. Removing them orphans os, Literal, urlencode and urlunparse, plus the _WFS_JSON_FORMATS constant; drop those too. CHUNK_SIZE was neither exported in __all__ nor referenced by any env file or documentation. --- .../src/data_manipulation/ingestion.py | 56 +------------------ 1 file changed, 1 insertion(+), 55 deletions(-) diff --git a/libs/data_manipulation/src/data_manipulation/ingestion.py b/libs/data_manipulation/src/data_manipulation/ingestion.py index a0382381..dad02bbf 100644 --- a/libs/data_manipulation/src/data_manipulation/ingestion.py +++ b/libs/data_manipulation/src/data_manipulation/ingestion.py @@ -1,13 +1,11 @@ import logging -import os import re import subprocess import tempfile import zipfile from pathlib import Path -from typing import Literal from urllib.error import URLError -from urllib.parse import quote, unquote, urlencode, urlparse, urlunparse +from urllib.parse import quote, unquote, urlparse from urllib.request import urlretrieve import chardet @@ -29,9 +27,6 @@ # Bytes sampled for encoding detection. chardet's accuracy is unchanged for a sample # this size, and reading only a sample avoids loading multi-GB files into memory. _ENCODING_DETECT_BYTES = 256 * 1024 -# Number of rows read and written to PostGIS per chunk. Keeps the memory footprint low -# (only one chunk is held in memory / converted to WKB at a time) for large files. -CHUNK_SIZE = int(os.getenv("DATAFEEDER_CHUNK_SIZE", 50000)) # Bytes read per iteration when streaming an HTTP download to disk. _DOWNLOAD_CHUNK_SIZE = 1024 * 1024 # GDAL error lines, e.g. "ERROR 1: Non UTF-8 content found ...". Anchored at the @@ -537,7 +532,6 @@ def ingest_data_from_database_into_postgis( _GDAL_PROTOCOL_PREFIX = {"wfs": "WFS", "ogcFeatures": "OAPIF"} _OAPIF_COLLECTIONS_RE = re.compile(r"/collections(/.*)?$") -_WFS_JSON_FORMATS = ("application/json", "application/geo+json", "json", "geojson") def _normalize_oapif_url(url: str) -> str: @@ -545,54 +539,6 @@ def _normalize_oapif_url(url: str) -> str: return _OAPIF_COLLECTIONS_RE.sub("", url.rstrip("/")) -def _wfs_json_output_format(service_url: str) -> str | None: - """Return the first JSON-compatible outputFormat advertised by GetCapabilities, or None.""" - try: - resp = requests.get( - service_url, - params={"SERVICE": "WFS", "REQUEST": "GetCapabilities"}, - timeout=30, - ) - resp.raise_for_status() - root = ET.fromstring(resp.content) - advertised = { - el.text.strip().lower() - for el in root.iter() - if (el.tag.split("}")[-1] if "}" in el.tag else el.tag) == "Value" and el.text - } - for fmt in _WFS_JSON_FORMATS: - if fmt in advertised: - return fmt - except Exception as exc: - logger.warning("Could not read WFS GetCapabilities from %s: %s", service_url, exc) - return None - - -def _wfs_geojson_chunk_url( - service_url: str, - layer_name: str, - offset: int, - count: int, - output_format: str = "application/json", -) -> str: - """Build a WFS 2.0 GetFeature URL requesting JSON output with pagination. - - Bypasses the GML driver (and its curved-geometry issues) by requesting - a JSON format directly from the server. - """ - parsed = urlparse(service_url) - params = { - "SERVICE": "WFS", - "VERSION": "2.0.0", - "REQUEST": "GetFeature", - "TYPENAMES": layer_name, - "OUTPUTFORMAT": output_format, - "startIndex": str(offset), - "count": str(count), - } - return urlunparse(parsed._replace(query=urlencode(params))) - - def ingest_data_from_ogc_service_into_postgis( service_url: str, layer_name: str, From ad68758f8acc56870a8c643ff04e632e24bf714f Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Thu, 3 Sep 2026 10:07:58 +0200 Subject: [PATCH 19/24] chore(elt): refresh uv.lock after the airflow provider bump apps/elt/uv.lock still pinned apache-airflow-providers-fab, which this branch removed from pyproject.toml when moving to Airflow 3.2.2. Relock so the file matches its manifest. --- apps/elt/uv.lock | 352 +---------------------------------------------- 1 file changed, 6 insertions(+), 346 deletions(-) diff --git a/apps/elt/uv.lock b/apps/elt/uv.lock index f3195ad5..9b17e7a7 100644 --- a/apps/elt/uv.lock +++ b/apps/elt/uv.lock @@ -212,34 +212,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/9f/4e9d0fcdb837d917817464e0eba596f3f8c2e7207f68dafca11261ae8ef3/apache_airflow_providers_common_sql-1.36.0-py3-none-any.whl", hash = "sha256:2e6e3c61cfdc391bf71eb783c1b785e7bfde3bd2d638c632fea4d438a6e2a240", size = 92533, upload-time = "2026-05-11T15:44:28.995Z" }, ] -[[package]] -name = "apache-airflow-providers-fab" -version = "3.6.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "apache-airflow" }, - { name = "apache-airflow-providers-common-compat" }, - { name = "blinker" }, - { name = "cachetools" }, - { name = "flask" }, - { name = "flask-appbuilder" }, - { name = "flask-limiter" }, - { name = "flask-login" }, - { name = "flask-session" }, - { name = "flask-sqlalchemy" }, - { name = "flask-wtf" }, - { name = "jmespath" }, - { name = "marshmallow" }, - { name = "msgpack" }, - { name = "pyjwt" }, - { name = "werkzeug" }, - { name = "wtforms" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/8e/8f83f27721fe562d0267d1464bce958c84879cc1f15dcc7f7c2db730da13/apache_airflow_providers_fab-3.6.4.tar.gz", hash = "sha256:9929465a448726fb38aff47344529f36c10ddc41efb0187ad223ad66cc19afb3", size = 833913, upload-time = "2026-05-23T12:32:32.595Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/35/16b0ed3c463eb41bf772208e6c671512cc794348007e73ee6c08b1a0c72a/apache_airflow_providers_fab-3.6.4-py3-none-any.whl", hash = "sha256:8cc956cec46e27b49d7429c0e75fb2df5ba649e4294f7e696ae8c43b703f8378", size = 606521, upload-time = "2026-05-23T12:31:30.332Z" }, -] - [[package]] name = "apache-airflow-providers-postgres" version = "6.7.0" @@ -317,23 +289,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/ca/478018625c726131f9ea774eb839642ebeba09588792a56d5f7cd7f4e301/apache_airflow_task_sdk-1.2.2-py3-none-any.whl", hash = "sha256:709552227b8139b1264413fdc4f0ef3034dd0519c8adeaffd0c9c908a608e6c5", size = 492829, upload-time = "2026-05-29T05:20:51.182Z" }, ] -[[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, upload-time = "2026-03-06T21:48:40.916Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/88/e149b20246c4689e7d27163e4e3bb8946ef31617cfb3b9c427813483fe5b/apispec-6.10.0-py3-none-any.whl", hash = "sha256:8ff23e0de9a0ceb62ff70047241126315bd17b8d0565a567934c0156f4ddbb43", size = 31313, upload-time = "2026-03-06T21:48:39.404Z" }, -] - -[package.optional-dependencies] -yaml = [ - { name = "pyyaml" }, -] - [[package]] name = "argcomplete" version = "3.6.3" @@ -386,24 +341,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] -[[package]] -name = "blinker" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, -] - -[[package]] -name = "cachelib" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/a5/5eb041dbee71766704d44cf5dfb6950ab018be0fd02cd763ade09869e33c/cachelib-0.14.0.tar.gz", hash = "sha256:73fedcadd0ba818fb2bb9f3c7cd5fcc2a71e86286f1842f55f28d500faee17f1", size = 170320, upload-time = "2026-05-09T16:16:02.896Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/0e/5493f2078dece836979f4e28e3b2066064a6d66691d4b0888efc7c62f702/cachelib-0.14.0-py3-none-any.whl", hash = "sha256:4671000b032baa8fac47ad19850f4f522785cee764b4e04c5cfe8955a18d67de", size = 22746, upload-time = "2026-05-09T16:16:01.68Z" }, -] - [[package]] name = "cachetools" version = "7.1.4" @@ -468,12 +405,12 @@ version = "7.4.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, - { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, - { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, - { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, - { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, - { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, ] @@ -643,7 +580,6 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "apache-airflow" }, - { name = "apache-airflow-providers-fab" }, { name = "apache-airflow-providers-postgres" }, { name = "data-manipulation" }, ] @@ -657,7 +593,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "apache-airflow", specifier = "==3.2.2" }, - { name = "apache-airflow-providers-fab", specifier = "==3.6.4" }, { name = "apache-airflow-providers-postgres", specifier = "==6.7.0" }, { name = "data-manipulation", editable = "../../libs/data_manipulation" }, ] @@ -758,153 +693,6 @@ standard-no-fastapi-cloud-cli = [ { name = "uvicorn", extra = ["standard"] }, ] -[[package]] -name = "flask" -version = "3.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "blinker" }, - { name = "click" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "markupsafe" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, -] - -[[package]] -name = "flask-appbuilder" -version = "5.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "apispec", extra = ["yaml"] }, - { name = "click" }, - { name = "colorama" }, - { name = "email-validator" }, - { name = "flask" }, - { name = "flask-babel" }, - { name = "flask-jwt-extended" }, - { name = "flask-limiter" }, - { name = "flask-login" }, - { name = "flask-sqlalchemy" }, - { name = "flask-wtf" }, - { name = "jsonschema" }, - { name = "marshmallow" }, - { name = "marshmallow-sqlalchemy" }, - { name = "prison" }, - { name = "pyjwt" }, - { name = "python-dateutil" }, - { name = "sqlalchemy" }, - { name = "sqlalchemy-utils" }, - { name = "werkzeug" }, - { name = "wtforms" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/97/b2/ad1784b3393cda84fd68b90689b1d6db037c13a62a9726ef639068bf8a75/flask_appbuilder-5.2.1.tar.gz", hash = "sha256:a5f6f34aa4ae0092a9eeb5051dc210869d9360429a429b0be88416c2616e1281", size = 7077970, upload-time = "2026-04-09T11:06:35.649Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/af/bdfd96170b1159ddc0e8424dfebbbac93d7270a1a749b0857fdaf384f027/flask_appbuilder-5.2.1-py3-none-any.whl", hash = "sha256:c5a134870fc0b780ac8d9de15fea8c470613ebe44feeddf01a2c18d2c066e144", size = 2217302, upload-time = "2026-04-09T11:06:32.891Z" }, -] - -[[package]] -name = "flask-babel" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "flask" }, - { name = "jinja2" }, - { name = "pytz" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/1a/4c65e3b90bda699a637bfb7fb96818b0a9bbff7636ea91aade67f6020a31/flask_babel-4.0.0.tar.gz", hash = "sha256:dbeab4027a3f4a87678a11686496e98e1492eb793cbdd77ab50f4e9a2602a593", size = 10178, upload-time = "2023-10-02T01:10:49.914Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/c2/e0ab5abe37882e118482884f2ec660cd06da644ddfbceccf5f88f546b574/flask_babel-4.0.0-py3-none-any.whl", hash = "sha256:638194cf91f8b301380f36d70e2034c77ee25b98cb5d80a1626820df9a6d4625", size = 9602, upload-time = "2023-10-02T01:10:48.58Z" }, -] - -[[package]] -name = "flask-jwt-extended" -version = "4.7.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flask" }, - { name = "pyjwt" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/20/bf/75189cf38cd391dddeb097001be3bc9ec24a8cae5a5a3698cd0a3fcaa182/flask_jwt_extended-4.7.4.tar.gz", hash = "sha256:78fd0f460317facf3a0084a6457ffaf2f1dda9eefbd576f94cea35b0eadd5531", size = 34672, upload-time = "2026-05-13T15:23:17.664Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/38/547a19f8ed0460e8c67c5b9e56ad72002fb06a1862fb786ef071ff03b9df/flask_jwt_extended-4.7.4-py2.py3-none-any.whl", hash = "sha256:daad1981117f4972d63c363d013f290de307aad781a935921b603b714817393c", size = 22699, upload-time = "2026-05-13T15:23:16.503Z" }, -] - -[[package]] -name = "flask-limiter" -version = "3.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flask" }, - { name = "limits" }, - { name = "ordered-set" }, - { name = "rich" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/70/75/92b237dd4f6e19196bc73007fff288ab1d4c64242603f3c401ff8fc58a42/flask_limiter-3.12.tar.gz", hash = "sha256:f9e3e3d0c4acd0d1ffbfa729e17198dd1042f4d23c130ae160044fc930e21300", size = 303162, upload-time = "2025-03-15T02:23:10.734Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/ba/40dafa278ee6a4300179d2bf59a1aa415165c26f74cfa17462132996186b/flask_limiter-3.12-py3-none-any.whl", hash = "sha256:b94c9e9584df98209542686947cf647f1ede35ed7e4ab564934a2bb9ed46b143", size = 28490, upload-time = "2025-03-15T02:23:08.919Z" }, -] - -[[package]] -name = "flask-login" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flask" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" }, -] - -[[package]] -name = "flask-session" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cachelib" }, - { name = "flask" }, - { name = "msgspec" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/86/d7/0ba4180513abe28eadc208123c76f9f09e290d5939fb2eb68323b9733354/flask_session-0.8.0.tar.gz", hash = "sha256:20e045eb01103694e70be4a49f3a80dbb1b57296a22dc6f44bbf3f83ef0742ff", size = 940269, upload-time = "2024-03-26T07:56:13.747Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/1b/f085ceebb825d1cfaf078852b67cd248a33af2905f40ba9860cc006d966b/flask_session-0.8.0-py3-none-any.whl", hash = "sha256:5dae6e9ddab334f8dc4dea4305af37851f4e7dc0f484caf3351184001195e3b7", size = 24410, upload-time = "2024-03-26T07:56:11.377Z" }, -] - -[[package]] -name = "flask-sqlalchemy" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flask" }, - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" }, -] - -[[package]] -name = "flask-wtf" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flask" }, - { name = "itsdangerous" }, - { name = "wtforms" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/f1/605a56d4ea217b307f3e6f4d663e0351253d85d841edc93ba559f0648e19/flask_wtf-1.3.0.tar.gz", hash = "sha256:61d5dabc50c3df885c297dcbd80810443a5d632106c8a69cab8ce740f0cdd7cc", size = 50414, upload-time = "2026-04-23T07:41:55.096Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/d2/97adf2ec7af95522573e6dd5493ee84792d0fbfb2def010c4a581b8d6e5e/flask_wtf-1.3.0-py3-none-any.whl", hash = "sha256:dc5e3a4ce97f75c47bf6c1c72ad2c3b7bdf579a2ed13aebcc5d3d81fe2571160", size = 13959, upload-time = "2026-04-23T07:41:53.828Z" }, -] - [[package]] name = "fsspec" version = "2026.4.0" @@ -1108,15 +896,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jmespath" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -1191,20 +970,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, ] -[[package]] -name = "limits" -version = "5.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deprecated" }, - { name = "packaging" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, -] - [[package]] name = "linkify-it-py" version = "2.1.0" @@ -1306,28 +1071,6 @@ wheels = [ { 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" }, ] -[[package]] -name = "marshmallow" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/7e/1dbd4096eb7c148cd2841841916f78820bb85a4d80a0c25c02d30815a7fb/marshmallow-4.3.0.tar.gz", hash = "sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880", size = 224485, upload-time = "2026-04-03T21:46:32.72Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/e0/ff24e25218bb59eb6290a530cea40651b14068b6e3659b20f9c175179632/marshmallow-4.3.0-py3-none-any.whl", hash = "sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46", size = 49148, upload-time = "2026-04-03T21:46:31.241Z" }, -] - -[[package]] -name = "marshmallow-sqlalchemy" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "marshmallow" }, - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ee/fe/247c297809e64116f766716632adbc3f4cd06f376f56dc15bb92f170d247/marshmallow_sqlalchemy-1.5.0.tar.gz", hash = "sha256:e51192c204770645a2fab0d72f44f8789272eef75951f84b1608d6b4b0bfe0e6", size = 51349, upload-time = "2026-04-01T23:21:03.833Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/b7/407c44dbd77a7670b7be6b7fedbe5329348fe00b4e62dae0f5c83a9aeafa/marshmallow_sqlalchemy-1.5.0-py3-none-any.whl", hash = "sha256:3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258", size = 16582, upload-time = "2026-04-01T23:21:02.376Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1358,23 +1101,6 @@ wheels = [ { 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]] -name = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, -] - [[package]] name = "msgspec" version = "0.21.1" @@ -1522,15 +1248,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, ] -[[package]] -name = "ordered-set" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" }, -] - [[package]] name = "outcome" version = "1.3.0.post0" @@ -1639,18 +1356,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/99/4aefb693b0f52783fab496492f4444a75137312c386eb7c3320f1b0a1602/poethepoet-0.45.0-py3-none-any.whl", hash = "sha256:8e25f6e834ecf25fe2ddca676a4e0207eeb2e19def0a8709fc5c7f18e86cd68c", size = 123920, upload-time = "2026-04-28T21:04:57.66Z" }, ] -[[package]] -name = "prison" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/65/4456caa4e9bbd1d4d4b5eecaea41bb2cd31efe0e7e423c7a9ad8e2be75ea/prison-0.2.1.tar.gz", hash = "sha256:e6cd724044afcb1a8a69340cad2f1e3151a5839fd3a8027fd1357571e797c599", size = 12040, upload-time = "2021-08-26T18:58:48.128Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/bd/e55e14cd213174100be0353824f2add41e8996c6f32081888897e8ec48b5/prison-0.2.1-py2.py3-none-any.whl", hash = "sha256:f90bab63fca497aa0819a852f64fb21a4e181ed9f6114deaa5dc04001a7555c5", size = 5794, upload-time = "2021-08-26T18:58:46.254Z" }, -] - [[package]] name = "protobuf" version = "6.33.6" @@ -1923,15 +1628,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, ] -[[package]] -name = "pytz" -version = "2026.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -2189,18 +1885,6 @@ asyncio = [ { name = "greenlet" }, ] -[[package]] -name = "sqlalchemy-utils" -version = "0.42.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/7d/eb9565b6a49426552a5bf5c57e7c239c506dc0e4e5315aec6d1e8241dc7c/sqlalchemy_utils-0.42.1.tar.gz", hash = "sha256:881f9cd9e5044dc8f827bccb0425ce2e55490ce44fc0bb848c55cc8ee44cc02e", size = 130789, upload-time = "2025-12-13T03:14:13.591Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/25/7400c18c3ee97914cc99c90007795c00a4ec5b60c853b49db7ba24d11179/sqlalchemy_utils-0.42.1-py3-none-any.whl", hash = "sha256:243cfe1b3a1dae3c74118ae633f1d1e0ed8c787387bc33e556e37c990594ac80", size = 91761, upload-time = "2025-12-13T03:14:15.014Z" }, -] - [[package]] name = "sqlparse" version = "0.5.5" @@ -2454,18 +2138,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] -[[package]] -name = "werkzeug" -version = "3.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, -] - [[package]] name = "wirerope" version = "1.0.0" @@ -2509,18 +2181,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ] -[[package]] -name = "wtforms" -version = "3.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/91/ed9b517da898e3fb747566aa3c12a734bd64ea7449a0d25ec74ce8f8b8eb/wtforms-3.2.2.tar.gz", hash = "sha256:7b00c73f8670f35d4edb0293dcd81b980528bee72fd662b182aaba27ae570b93", size = 139583, upload-time = "2026-05-03T05:53:44.147Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/76/bb225c8300f3a0ba28e01df51419c6c9574a297c43d71b29048e03b65deb/wtforms-3.2.2-py3-none-any.whl", hash = "sha256:72b90d5d921bd3119252069cf0301e9c13915f9e52792652bc91c5dda4b79e56", size = 158656, upload-time = "2026-05-03T05:53:46.072Z" }, -] - [[package]] name = "xmltodict" version = "1.0.4" From 55634fceef4218b698c9f69cfe276f329cfd42b9 Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Fri, 4 Sep 2026 16:52:14 +0200 Subject: [PATCH 20/24] fix(tests): realign data_manipulation tests with the current code `make test-libs` was red on main and use-gdal: 13 failures across three files, all of them tests left behind by refactors rather than actual defects. - test_utils: sanitize_name replaces hyphens with underscores (PostgreSQL identifiers reject them unquoted), so the two tests asserting hyphens are preserved were inverted. Its docstring contradicted its own examples and is fixed too. - test_database: schema_exists/table_exists now go through sqlalchemy.inspect(), which rejects the MagicMock engine the tests injected via engine.connect(). Patch inspect() instead. - test_geoserver: create_workspace/create_layer moved to RestService (create_datastore with a DataStore, create_feature_type with a FeatureType); the tests still expected create_jndi_datastore/create_feature_type on the GeoServerCloud object. The non-geographic test also asserted inverted placeholder bounds. Adds three cases covering the default-derivation branches that had none. --- .../src/data_manipulation/utils.py | 8 +- libs/data_manipulation/tests/test_database.py | 46 +-- .../data_manipulation/tests/test_geoserver.py | 358 +++++++++--------- libs/data_manipulation/tests/test_utils.py | 15 +- 4 files changed, 208 insertions(+), 219 deletions(-) diff --git a/libs/data_manipulation/src/data_manipulation/utils.py b/libs/data_manipulation/src/data_manipulation/utils.py index b66073f2..19945aac 100644 --- a/libs/data_manipulation/src/data_manipulation/utils.py +++ b/libs/data_manipulation/src/data_manipulation/utils.py @@ -19,10 +19,10 @@ def sanitize_name(name: str, max_length: int = PG_IDENTIFIER_MAX_LENGTH) -> str: Sanitize a name for use in GeoServer workspace/layer names or database schema names. Removes or replaces special characters: - - Replaces spaces with underscores - - Removes any character that is not alphanumeric, underscore, or hyphen + - Replaces spaces and hyphens with underscores + - Removes any character that is not alphanumeric or underscore - Converts to lowercase for consistency - - Removes leading/trailing underscores or hyphens + - Removes leading/trailing underscores - Ensures the name doesn't start with a number (prefixes with 'layer_' if it does) Args: @@ -40,7 +40,7 @@ def sanitize_name(name: str, max_length: int = PG_IDENTIFIER_MAX_LENGTH) -> str: >>> sanitize_name("Org@123 #Test!") 'org123_test' >>> sanitize_name("test--layer__name") - 'test_layer_name' + 'test__layer__name' >>> sanitize_name("123_dataset") 'layer_123_dataset' >>> sanitize_name("_MyOrg_") diff --git a/libs/data_manipulation/tests/test_database.py b/libs/data_manipulation/tests/test_database.py index 54560534..c1c34ce2 100644 --- a/libs/data_manipulation/tests/test_database.py +++ b/libs/data_manipulation/tests/test_database.py @@ -1,6 +1,6 @@ """Tests for database utility functions.""" -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock, patch from data_manipulation.database import schema_exists, table_exists @@ -9,41 +9,37 @@ class TestSchemaExists: """Test cases for schema_exists function.""" def test_schema_exists_returns_true(self) -> None: - mock_conn = MagicMock() - mock_conn.execute.return_value.fetchone.return_value = ("public",) - mock_engine = MagicMock() - mock_engine.connect.return_value.__enter__ = Mock(return_value=mock_conn) - mock_engine.connect.return_value.__exit__ = Mock(return_value=False) + inspector = MagicMock() + inspector.has_schema.return_value = True - assert schema_exists(mock_engine, "public") is True + with patch("data_manipulation.database.inspect", return_value=inspector): + assert schema_exists(MagicMock(), "public") is True + + inspector.has_schema.assert_called_once_with("public") def test_schema_not_exists_returns_false(self) -> None: - mock_conn = MagicMock() - mock_conn.execute.return_value.fetchone.return_value = None - mock_engine = MagicMock() - mock_engine.connect.return_value.__enter__ = Mock(return_value=mock_conn) - mock_engine.connect.return_value.__exit__ = Mock(return_value=False) + inspector = MagicMock() + inspector.has_schema.return_value = False - assert schema_exists(mock_engine, "nonexistent") is False + with patch("data_manipulation.database.inspect", return_value=inspector): + assert schema_exists(MagicMock(), "nonexistent") is False class TestTableExists: """Test cases for table_exists function.""" def test_table_exists_returns_true(self) -> None: - mock_conn = MagicMock() - mock_conn.execute.return_value.fetchone.return_value = ("my_table",) - mock_engine = MagicMock() - mock_engine.connect.return_value.__enter__ = Mock(return_value=mock_conn) - mock_engine.connect.return_value.__exit__ = Mock(return_value=False) + inspector = MagicMock() + inspector.has_table.return_value = True + + with patch("data_manipulation.database.inspect", return_value=inspector): + assert table_exists(MagicMock(), "public", "my_table") is True - assert table_exists(mock_engine, "public", "my_table") is True + inspector.has_table.assert_called_once_with("my_table", schema="public") def test_table_not_exists_returns_false(self) -> None: - mock_conn = MagicMock() - mock_conn.execute.return_value.fetchone.return_value = None - mock_engine = MagicMock() - mock_engine.connect.return_value.__enter__ = Mock(return_value=mock_conn) - mock_engine.connect.return_value.__exit__ = Mock(return_value=False) + inspector = MagicMock() + inspector.has_table.return_value = False - assert table_exists(mock_engine, "public", "nonexistent") is False + with patch("data_manipulation.database.inspect", return_value=inspector): + assert table_exists(MagicMock(), "public", "nonexistent") is False diff --git a/libs/data_manipulation/tests/test_geoserver.py b/libs/data_manipulation/tests/test_geoserver.py index 68c16089..cebedc27 100644 --- a/libs/data_manipulation/tests/test_geoserver.py +++ b/libs/data_manipulation/tests/test_geoserver.py @@ -11,43 +11,65 @@ ) +def _geoserver_mock() -> MagicMock: + """GeoServerCloud mock wired for the REST calls create_workspace makes.""" + geoserver = MagicMock() + geoserver.rest_service.rest_client.get.return_value.json.return_value = { + "namespace": {"uri": "http://test_workspace"} + } + return geoserver + + class TestCreateWorkspace: """Test cases for create_workspace function.""" @pytest.fixture def mock_geoserver(self) -> MagicMock: """Create a mock GeoServerCloud instance.""" - return MagicMock() + return _geoserver_mock() def test_create_workspace_success(self, mock_geoserver: MagicMock) -> None: """Test successful workspace and datastore creation.""" - workspace_name = "test_workspace" - datastore_name = "test_datastore" - jndi_reference = "jdbc/datafeeder" - pg_schema = "test_schema" - description = "Test description" - - create_workspace( + result = create_workspace( geoserver=mock_geoserver, - workspace_name=workspace_name, - datastore_name=datastore_name, - jndi_reference=jndi_reference, - pg_schema=pg_schema, - description=description, + workspace_name="test_workspace", + datastore_name="test_datastore", + jndi_reference="jdbc/datafeeder", + pg_schema="test_schema", + description="Test description", ) - # Verify workspace creation was called - mock_geoserver.create_workspace.assert_called_once_with(workspace_name) - - # Verify JNDI datastore creation was called with correct parameters - mock_geoserver.create_jndi_datastore.assert_called_once_with( - workspace_name=workspace_name, - datastore_name=datastore_name, - jndi_reference=jndi_reference, - pg_schema=pg_schema, - description=description, + mock_geoserver.create_workspace.assert_called_once_with("test_workspace") + + mock_geoserver.rest_service.create_datastore.assert_called_once() + kwargs = mock_geoserver.rest_service.create_datastore.call_args.kwargs + assert kwargs["workspace_name"] == "test_workspace" + datastore = kwargs["datastore"] + assert datastore.connection_parameters["jndiReferenceName"] == "jdbc/datafeeder" + assert datastore.connection_parameters["schema"] == "test_schema" + # The namespace must match the one GeoServer reports for the workspace, not a + # pattern guessed from its name. + assert datastore.connection_parameters["namespace"] == "http://test_workspace" + + assert result.workspace == "test_workspace" + assert result.datastore == "test_datastore" + assert result.pg_schema == "test_schema" + + def test_create_workspace_defaults_schema_to_workspace_name( + self, mock_geoserver: MagicMock + ) -> None: + """Test that a None pg_schema falls back to the sanitized workspace name.""" + result = create_workspace( + geoserver=mock_geoserver, + workspace_name="Test Workspace", + datastore_name="test_datastore", + jndi_reference="jdbc/datafeeder", + pg_schema=None, ) + assert result.workspace == "test_workspace" + assert result.pg_schema == "test_workspace" + def test_create_workspace_handles_workspace_error(self, mock_geoserver: MagicMock) -> None: """Test that workspace creation errors are propagated.""" mock_geoserver.create_workspace.side_effect = Exception("Workspace creation failed") @@ -62,14 +84,14 @@ def test_create_workspace_handles_workspace_error(self, mock_geoserver: MagicMoc description="Test description", ) - # Verify workspace creation was attempted mock_geoserver.create_workspace.assert_called_once() - # Verify datastore creation was not attempted - mock_geoserver.create_jndi_datastore.assert_not_called() + mock_geoserver.rest_service.create_datastore.assert_not_called() def test_create_workspace_handles_datastore_error(self, mock_geoserver: MagicMock) -> None: """Test that datastore creation errors are propagated.""" - mock_geoserver.create_jndi_datastore.side_effect = Exception("Datastore creation failed") + mock_geoserver.rest_service.create_datastore.side_effect = Exception( + "Datastore creation failed" + ) with pytest.raises(Exception, match="Datastore creation failed"): create_workspace( @@ -81,9 +103,8 @@ def test_create_workspace_handles_datastore_error(self, mock_geoserver: MagicMoc description="Test description", ) - # Verify both operations were attempted mock_geoserver.create_workspace.assert_called_once() - mock_geoserver.create_jndi_datastore.assert_called_once() + mock_geoserver.rest_service.create_datastore.assert_called_once() class TestCreateLayer: @@ -92,195 +113,166 @@ class TestCreateLayer: @pytest.fixture def mock_geoserver(self) -> MagicMock: """Create a mock GeoServerCloud instance.""" - return MagicMock() + geoserver = MagicMock() + geoserver.url = "http://localhost:8080/geoserver" + geoserver.auth = ("admin", "geoserver") + return geoserver def test_create_layer_success(self, mock_geoserver: MagicMock) -> None: """Test successful layer creation.""" - workspace_name = "test_workspace" - datastore_name = "test_datastore" - table_name = "test_table" - title = "Test Layer" - abstract = "Test layer description" + with patch("data_manipulation.geoserver.RestService") as rest_service_class: + rest_service = rest_service_class.return_value - create_layer( - geoserver=mock_geoserver, - workspace_name=workspace_name, - datastore_name=datastore_name, - table_name=table_name, - title=title, - abstract=abstract, - ) + create_layer( + geoserver=mock_geoserver, + workspace_name="test_workspace", + datastore_name="test_datastore", + table_name="test_table", + title="Test Layer", + abstract="Test layer description", + ) - # Verify feature type creation was called - mock_geoserver.create_feature_type.assert_called_once_with( - layer_name=table_name, - workspace_name=workspace_name, - datastore_name=datastore_name, - title=title, - abstract=abstract, - epsg=4326, + rest_service_class.assert_called_once_with( + url=mock_geoserver.url, + auth=mock_geoserver.auth, ) - - # Verify get_feature_type was not called (no error occurred) + rest_service.create_feature_type.assert_called_once() + + feature_type = rest_service.create_feature_type.call_args[0][0] + assert isinstance(feature_type, FeatureType) + payload = feature_type.post_payload()["featureType"] + assert payload["name"] == "test_table" + assert payload["nativeName"] == "test_table" + assert payload["title"] == "Test Layer" + assert payload["abstract"] == "Test layer description" + assert payload["srs"] == "EPSG:4326" + + # No error, so the existence fallback must not run. mock_geoserver.get_feature_type.assert_not_called() + def test_create_layer_defaults_datastore_title_and_abstract( + self, mock_geoserver: MagicMock + ) -> None: + """Test that omitted datastore/title/abstract are derived from the table name.""" + with patch("data_manipulation.geoserver.RestService") as rest_service_class: + rest_service = rest_service_class.return_value + + create_layer( + geoserver=mock_geoserver, + workspace_name="test_workspace", + datastore_name=None, + table_name="test_table", + ) + + feature_type = rest_service.create_feature_type.call_args[0][0] + payload = feature_type.post_payload()["featureType"] + assert payload["title"] == "test_table" + assert payload["abstract"] == "test_table" + assert payload["store"]["name"] == "test_workspace:test_workspace_ds" + def test_create_layer_with_error_but_layer_exists(self, mock_geoserver: MagicMock) -> None: """Test layer creation when create_feature_type fails but layer exists.""" - workspace_name = "test_workspace" - datastore_name = "test_datastore" - table_name = "test_table" - title = "Test Layer" - abstract = "Test layer description" - - # Mock create_feature_type to raise an exception - mock_geoserver.create_feature_type.side_effect = Exception("500 Server Error") - # Mock get_feature_type to succeed (layer exists) - mock_geoserver.get_feature_type.return_value = {"name": table_name} - - # Should not raise an exception - create_layer( - geoserver=mock_geoserver, - workspace_name=workspace_name, - datastore_name=datastore_name, - table_name=table_name, - title=title, - abstract=abstract, - ) + mock_geoserver.get_feature_type.return_value = {"name": "test_table"} + + with patch("data_manipulation.geoserver.RestService") as rest_service_class: + rest_service = rest_service_class.return_value + rest_service.create_feature_type.side_effect = Exception("500 Server Error") + + # The layer exists despite the error, so this must not raise. + create_layer( + geoserver=mock_geoserver, + workspace_name="test_workspace", + datastore_name="test_datastore", + table_name="test_table", + title="Test Layer", + abstract="Test layer description", + ) - # Verify both methods were called - mock_geoserver.create_feature_type.assert_called_once() + rest_service.create_feature_type.assert_called_once() mock_geoserver.get_feature_type.assert_called_once_with( - workspace_name=workspace_name, - datastore_name=datastore_name, - feature_type_name=table_name, + workspace_name="test_workspace", + datastore_name="test_datastore", + feature_type_name="test_table", ) def test_create_layer_with_error_and_layer_not_exists(self, mock_geoserver: MagicMock) -> None: """Test layer creation when create_feature_type fails and layer doesn't exist.""" - workspace_name = "test_workspace" - datastore_name = "test_datastore" - table_name = "test_table" - title = "Test Layer" - abstract = "Test layer description" - - error_message = "Table does not exist" - - # Mock create_feature_type to raise an exception - mock_geoserver.create_feature_type.side_effect = Exception(error_message) - # Mock get_feature_type to also fail (layer doesn't exist) mock_geoserver.get_feature_type.side_effect = Exception("Layer not found") - # Should raise an exception with the original error - with pytest.raises(Exception, match=f"Failed to create layer '{table_name}' in GeoServer"): - create_layer( - geoserver=mock_geoserver, - workspace_name=workspace_name, - datastore_name=datastore_name, - table_name=table_name, - title=title, - abstract=abstract, - ) - - # Verify both methods were called - mock_geoserver.create_feature_type.assert_called_once() + with patch("data_manipulation.geoserver.RestService") as rest_service_class: + rest_service = rest_service_class.return_value + rest_service.create_feature_type.side_effect = Exception("Table does not exist") + + with pytest.raises(Exception, match="Failed to create layer 'test_table' in GeoServer"): + create_layer( + geoserver=mock_geoserver, + workspace_name="test_workspace", + datastore_name="test_datastore", + table_name="test_table", + title="Test Layer", + abstract="Test layer description", + ) + + rest_service.create_feature_type.assert_called_once() mock_geoserver.get_feature_type.assert_called_once() def test_create_layer_propagates_real_error(self, mock_geoserver: MagicMock) -> None: """Test that real errors during layer creation are propagated.""" - workspace_name = "test_workspace" - datastore_name = "test_datastore" - table_name = "nonexistent_table" - title = "Test Layer" - abstract = "Test layer description" - - original_error = "Connection timeout" - mock_geoserver.create_feature_type.side_effect = Exception(original_error) mock_geoserver.get_feature_type.side_effect = Exception("Layer not found") - with pytest.raises(Exception) as exc_info: - create_layer( - geoserver=mock_geoserver, - workspace_name=workspace_name, - datastore_name=datastore_name, - table_name=table_name, - title=title, - abstract=abstract, + with patch("data_manipulation.geoserver.RestService") as rest_service_class: + rest_service_class.return_value.create_feature_type.side_effect = Exception( + "Connection timeout" ) - # Verify the error message contains the original error - assert original_error in str(exc_info.value) - assert table_name in str(exc_info.value) + with pytest.raises(Exception) as exc_info: + create_layer( + geoserver=mock_geoserver, + workspace_name="test_workspace", + datastore_name="test_datastore", + table_name="nonexistent_table", + title="Test Layer", + abstract="Test layer description", + ) + + assert "Connection timeout" in str(exc_info.value) + assert "nonexistent_table" in str(exc_info.value) def test_create_layer_non_geographic_success(self, mock_geoserver: MagicMock) -> None: """Test successful layer creation for non-geographic data with fake bounds.""" - workspace_name = "test_workspace" - datastore_name = "test_datastore" - table_name = "test_table" - title = "Test Non-Geographic Layer" - abstract = "Test non-geographic layer description" epsg = 2154 - mock_geoserver.url = "http://localhost:8080/geoserver" - mock_geoserver.auth = ("admin", "geoserver") - - with patch("data_manipulation.geoserver.RestService") as mock_rest_service_class: - mock_rest_service_instance = MagicMock() - mock_rest_service_class.return_value = mock_rest_service_instance + with patch("data_manipulation.geoserver.RestService") as rest_service_class: + rest_service = rest_service_class.return_value create_layer( geoserver=mock_geoserver, - workspace_name=workspace_name, - datastore_name=datastore_name, - table_name=table_name, - title=title, - abstract=abstract, + workspace_name="test_workspace", + datastore_name="test_datastore", + table_name="test_table", + title="Test Non-Geographic Layer", + abstract="Test non-geographic layer description", epsg=epsg, is_geographic=False, ) - # Verify that geoserver.create_feature_type was NOT called - mock_geoserver.create_feature_type.assert_not_called() - - # Verify that RestService was instantiated with correct parameters - mock_rest_service_class.assert_called_once_with( - url=mock_geoserver.url, - auth=mock_geoserver.auth, - ) - - # Verify that RestService.create_feature_type was called - mock_rest_service_instance.create_feature_type.assert_called_once() - - # Get the FeatureType object that was passed to create_feature_type - call_args = mock_rest_service_instance.create_feature_type.call_args - feature_type_arg = call_args[0][0] - - # Verify that a FeatureType was passed - assert isinstance(feature_type_arg, FeatureType) - - # Get the serialized payload to verify the fake bounds - feature_type_dict = feature_type_arg.post_payload() - feature_type_data = feature_type_dict["featureType"] - - # Verify basic properties - assert feature_type_data["name"] == table_name - assert feature_type_data["nativeName"] == table_name - assert feature_type_data["title"] == title - assert feature_type_data["abstract"] == abstract - assert feature_type_data["srs"] == f"EPSG:{epsg}" - - # Verify the native bounding box has fake bounds - native_bbox = feature_type_data["nativeBoundingBox"] - assert native_bbox["minx"] == 0 - assert native_bbox["miny"] == 0 - assert native_bbox["maxx"] == -1 - assert native_bbox["maxy"] == -1 - assert native_bbox["crs"]["$"] == f"EPSG:{epsg}" - assert native_bbox["crs"]["@class"] == "projected" - - # Verify the lat/lon bounding box has fake bounds - latlon_bbox = feature_type_data["latLonBoundingBox"] - assert latlon_bbox["minx"] == -1 - assert latlon_bbox["miny"] == -1 - assert latlon_bbox["maxx"] == 0 - assert latlon_bbox["maxy"] == 0 - assert latlon_bbox["crs"] == f"EPSG:{epsg}" + rest_service.create_feature_type.assert_called_once() + feature_type = rest_service.create_feature_type.call_args[0][0] + assert isinstance(feature_type, FeatureType) + payload = feature_type.post_payload()["featureType"] + + assert payload["name"] == "test_table" + assert payload["srs"] == f"EPSG:{epsg}" + + # is_geographic=False skips bbox derivation, so the default placeholder bounds + # are sent as-is and GeoServer treats the layer as having no valid extent. + native_bbox = payload["nativeBoundingBox"] + assert (native_bbox["minx"], native_bbox["miny"]) == (-1.0, -1.0) + assert (native_bbox["maxx"], native_bbox["maxy"]) == (0.0, 0.0) + assert native_bbox["crs"]["$"] == f"EPSG:{epsg}" + assert native_bbox["crs"]["@class"] == "projected" + + latlon_bbox = payload["latLonBoundingBox"] + assert (latlon_bbox["minx"], latlon_bbox["miny"]) == (-1.0, -1.0) + assert (latlon_bbox["maxx"], latlon_bbox["maxy"]) == (0.0, 0.0) + assert latlon_bbox["crs"] == "EPSG:4326" diff --git a/libs/data_manipulation/tests/test_utils.py b/libs/data_manipulation/tests/test_utils.py index 07d61ecf..bbd27d46 100644 --- a/libs/data_manipulation/tests/test_utils.py +++ b/libs/data_manipulation/tests/test_utils.py @@ -28,10 +28,11 @@ def test_special_characters_removed(self): assert sanitize_name("name&with*symbols") == "namewithsymbols" assert sanitize_name("email@domain.com") == "emaildomaincom" - def test_hyphens_preserved(self): - """Test that hyphens are preserved (they're allowed).""" - assert sanitize_name("test-layer-name") == "test-layer-name" - assert sanitize_name("my-org-123") == "my-org-123" + def test_hyphens_replaced_with_underscores(self): + """Test that hyphens become underscores (PostgreSQL identifiers reject them + unless quoted).""" + assert sanitize_name("test-layer-name") == "test_layer_name" + assert sanitize_name("my-org-123") == "my_org_123" def test_underscores_preserved(self): """Test that underscores are preserved.""" @@ -39,9 +40,9 @@ def test_underscores_preserved(self): assert sanitize_name("my_org_123") == "my_org_123" def test_multiple_underscores_and_hyphens(self): - """Test that multiple consecutive underscores/hyphens are preserved.""" - assert sanitize_name("test--layer__name") == "test--layer__name" - assert sanitize_name("name___with---many") == "name___with---many" + """Test that consecutive separators are kept one-for-one, hyphens included.""" + assert sanitize_name("test--layer__name") == "test__layer__name" + assert sanitize_name("name___with---many") == "name___with___many" def test_leading_trailing_underscores_removed(self): """Test that leading and trailing underscores are removed.""" From ed630f803c8fa6dd554bb05348a7930d3ab7b71e Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Fri, 4 Sep 2026 16:52:20 +0200 Subject: [PATCH 21/24] fix(docker): give the GDAL sidecar the Parquet driver The datafeeder-gdal sidecar used by the LOCAL task executor ran gdal:alpine-small, which ships no Parquet driver: ogrinfo on a .parquet fails with "unable to open". The backend accepts parquet/geoparquet uploads (FileType.PARQUET), and ingestion hands the file straight to ogr2ogr, so that path was broken whenever TASK_EXECUTOR=LOCAL. alpine-normal has the driver (523MB instead of 96MB). The Airflow image is unaffected: it installs libgdal-arrow-parquet from conda-forge. --- docker/compose.datafeeder.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/compose.datafeeder.yaml b/docker/compose.datafeeder.yaml index 4f603a42..03877ec9 100644 --- a/docker/compose.datafeeder.yaml +++ b/docker/compose.datafeeder.yaml @@ -42,7 +42,9 @@ services: datafeeder-gdal: profiles: - local-executor - image: ghcr.io/osgeo/gdal:alpine-small-3.13.2 + # alpine-normal, not alpine-small: only the former ships the Parquet driver, + # which FileType.PARQUET in apps/backend/src/models/data_import.py accepts. + image: ghcr.io/osgeo/gdal:alpine-normal-3.13.2 container_name: datafeeder-gdal # Host networking so ogr2ogr reaches datadb the same way the host-run backend does # (localhost:5433, see POSTGRES_DATA_HOST/PORT in datafeeder.env) — the compose From f68d513c6db1c4a120a189d9489a9324183d38bc Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Fri, 4 Sep 2026 16:52:58 +0200 Subject: [PATCH 22/24] docs(docker): state the real reason the Airflow image needs Trixie The comments claimed Trixie was needed because "bookworm only ships GDAL 3.6". That justifies using conda-forge, not Trixie: no Debian release ships GDAL 3.13, so conda-forge is required either way. The actual constraint is ABI: the conda-forge build resolves libstdc++ as a direct NEEDED entry of the ogr2ogr binary, outside its own RUNPATH, so it picks up the system copy. Bookworm's libstdc++6 12.2 lacks GLIBCXX_3.4.31 / CXXABI_1.3.15 and ogr2ogr dies at exec time; Trixie's 14.2 works. Neither LD_LIBRARY_PATH nor ldconfig avoids this, both were tried. The base image README was wrong on three further points: - the vendored Dockerfile is not a verbatim copy: install_python() is rewritten to take Python from apt, with an early return leaving the upstream from-source build as dead code below it; - so it does not compile Python from source, and AIRFLOW_PYTHON_VERSION is ignored (the documented 3.14.0 never applied; the image ships Trixie's 3.13.5); - the patch list omitted that rewrite and described lcov as blocked by a from-source constraint that is no longer in effect. --- docker/Dockerfile.airflow | 29 +++++++++++++--------- docker/airflow-base/README.md | 46 +++++++++++++++++++++++------------ 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/docker/Dockerfile.airflow b/docker/Dockerfile.airflow index 7ded1610..f71bec32 100644 --- a/docker/Dockerfile.airflow +++ b/docker/Dockerfile.airflow @@ -39,10 +39,10 @@ RUN cd apps/elt \ # ============================================================================ # Base stage: Trixie based airflow image + uv, shared by development and -# production. The base image is built from the official Airflow Dockerfile -# (see docker/airflow-base and `make build-airflow-base`) on debian:trixie-slim -# with the system Python 3.13 shipped by debian:trixie-slim, matching the -# workspace's python pin. +# production. The base image is built from the official Airflow Dockerfile with +# local patches (see docker/airflow-base and `make build-airflow-base`) on +# debian:trixie-slim, using the Python that Trixie packages — currently 3.13, +# matching the workspace's python pin. # ============================================================================ FROM ${AIRFLOW_BASE_IMAGE} AS base @@ -65,14 +65,19 @@ ENV UV_LINK_MODE=copy \ UV_NO_CACHE=1 \ UV_FROZEN=1 -# GDAL > 3.9 from conda-forge. Bookworm only ships 3.6, so we install GDAL into -# an isolated prefix via micromamba and expose the CLI through small wrapper -# scripts (not bare symlinks). The wrappers force ${GDAL_PREFIX}/lib to the front -# of LD_LIBRARY_PATH so the conda libstdc++/libproj/... always win: libgdal.so -# carries no RUNPATH of its own, so any LD_LIBRARY_PATH inherited from the airflow -# worker would otherwise drag in bookworm's too-old libstdc++ (missing -# GLIBCXX_3.4.31 / CXXABI_1.3.15). We deliberately keep ${GDAL_PREFIX}/bin off the -# global PATH so conda's python never shadows the airflow interpreter. +# GDAL from conda-forge, which is the reason this image needs a Trixie base: no +# Debian release ships GDAL >= 3.13, and the conda-forge build links against the +# *system* libstdc++, requiring GLIBCXX_3.4.31 / CXXABI_1.3.15 (GCC 13+). On +# bookworm ogr2ogr installs fine but dies at exec time with "version +# `CXXABI_1.3.15' not found"; neither LD_LIBRARY_PATH nor ldconfig fixes it, +# because libstdc++ is a direct NEEDED entry of the ogr2ogr binary and is +# resolved outside the conda RUNPATH. +# GDAL goes into an isolated prefix and the CLI is exposed through small wrapper +# scripts (not bare symlinks) that put ${GDAL_PREFIX}/lib first in +# LD_LIBRARY_PATH, so conda's libproj/libicu/... win over any system copy the +# airflow worker may have in its environment. We deliberately keep +# ${GDAL_PREFIX}/bin off the global PATH so conda's python never shadows the +# airflow interpreter. # conda-forge splits GDAL drivers into per-format packages on top of # libgdal-core: libgdal-arrow-parquet provides the (Geo)Parquet driver and # libgdal-pg provides the live PostgreSQL/PostGIS driver (ogr_PG.so) that the diff --git a/docker/airflow-base/README.md b/docker/airflow-base/README.md index a786bab8..c5e68570 100644 --- a/docker/airflow-base/README.md +++ b/docker/airflow-base/README.md @@ -1,13 +1,18 @@ # Apache Airflow base image (Debian Trixie) -Apache only publishes `apache/airflow` images based on Debian **bookworm**. To run -Airflow on Debian **Trixie** we build the base image ourselves from the **official -Airflow Dockerfile**, which compiles Python from source on top of a `debian:*-slim` -base image (build arg `BASE_IMAGE`). +Apache only publishes `apache/airflow` images based on Debian **bookworm**. We need +Trixie because GDAL >= 3.13 from conda-forge (installed in `docker/Dockerfile.airflow`) +links against the *system* libstdc++ and requires `GLIBCXX_3.4.31` / `CXXABI_1.3.15`, +i.e. GCC 13+. Bookworm ships `libstdc++6` 12.2, so `ogr2ogr` installs fine there but +fails to load at runtime; Trixie ships 14.2, which is compatible. + +So we build the base image ourselves from the official Airflow Dockerfile on top of a +`debian:*-slim` base image (build arg `BASE_IMAGE`). ## Contents -- `Dockerfile` — verbatim copy of the official Airflow Dockerfile, tag `3.2.2` - (https://raw.githubusercontent.com/apache/airflow/3.2.2/Dockerfile). +- `Dockerfile` — the official Airflow Dockerfile, tag `3.2.2` + (https://raw.githubusercontent.com/apache/airflow/3.2.2/Dockerfile), plus the local + patches listed under [Trixie patch](#trixie-patch). - `scripts/docker/keys/` — apt/Python signing keys referenced by the Dockerfile, copied from the same tag. @@ -19,24 +24,33 @@ make build-airflow-base docker build \ --build-arg BASE_IMAGE=debian:trixie-slim \ --build-arg AIRFLOW_VERSION=3.2.2 \ - --build-arg AIRFLOW_PYTHON_VERSION=3.14.0 \ -t datafeeder-airflow-base:3.2.2-trixie \ docker/airflow-base ``` +Note that `AIRFLOW_PYTHON_VERSION` has no effect: the patched `install_python()` takes +Python from apt (see below), so the image gets whatever Trixie ships — currently 3.13.5, +which satisfies the workspace's `requires-python = "==3.13.*"`. Bumping the workspace to +3.14 therefore requires more than a build arg, since Trixie has no `python3.14` package. + The resulting `datafeeder-airflow-base:3.2.2-trixie` image is consumed as the `base` stage of `docker/Dockerfile.airflow` (build arg `AIRFLOW_BASE_IMAGE`). ## Upgrading 1. Download the official Dockerfile and `scripts/docker/keys/` for the new tag. -2. Bump `AIRFLOW_VERSION` / `AIRFLOW_PYTHON_VERSION` in the `Makefile` and - `apps/elt/pyproject.toml`, then regenerate `apps/elt/uv.lock`. +2. Re-apply the three patches below. +3. Bump `AIRFLOW_VERSION` in the `Makefile` and `apps/elt/pyproject.toml`, then + regenerate `apps/elt/uv.lock`. ## Trixie patch -Two minimal changes are applied to the upstream `DEV_APT_DEPS` list for Trixie -compatibility (re-apply them when refreshing from upstream): -- removed `lzma-dev`: bookworm-only transitional package, gone on Trixie and - fully covered by `liblzma-dev`. -- removed `lcov`: on Trixie it depends on the system Python (`libpython3.13`), - which the official Dockerfile forbids (Python is compiled from source). `lcov` - is only a coverage tool and is not needed to build/run the image. +Three changes are applied to the upstream Dockerfile (re-apply them when refreshing +from upstream): +- `install_python()` rewritten to install Python from apt instead of building it from + source, with an early `return 0` keeping the original body below it as dead code. + This is why `AIRFLOW_PYTHON_VERSION` is ignored and the Python version is whatever + Trixie packages. +- removed `lzma-dev` from `DEV_APT_DEPS`: bookworm-only transitional package, gone on + Trixie and fully covered by `liblzma-dev`. +- removed `lcov` from `DEV_APT_DEPS`: on Trixie it pulls in the system Python + (`libpython3.13`). It is only a coverage tool and is not needed to build/run the + image. From 402d7bebf95aa49d036a2d4e731d576e066d119b Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Mon, 7 Sep 2026 08:56:47 +0200 Subject: [PATCH 23/24] build(docker): base the Airflow image on the GDAL image The ELT shells out to ogr2ogr and needs GDAL >= 3.13, which no Debian release packages. Getting it onto the official apache/airflow image meant installing GDAL from conda-forge, and that build links against the *system* libstdc++: it needs GLIBCXX_3.4.31 / CXXABI_1.3.15 (GCC 13+), which bookworm's 12.2 lacks, so ogr2ogr installed fine but died at exec time. Neither LD_LIBRARY_PATH nor ldconfig fixes that, because libstdc++ is a direct NEEDED entry of the binary, resolved outside the conda prefix's RUNPATH. The workaround was a Trixie based Airflow base image, built from a 2293-line vendored copy of the upstream Airflow Dockerfile carrying three local patches. Invert the layering instead: start from ghcr.io/osgeo/gdal:ubuntu-full, where GDAL is native and glibc/libstdc++/GDAL are a coherent set, and install airflow on top from apps/elt/uv.lock. The official image is still pulled, but only to lift its /entrypoint and /clean-logs, which compose.airflow.yaml relies on. This drops the vendored Dockerfile, its three patches, the base image build target and its CI workflow. It also fixes the airflow build in build-docker-images.yml, which ran `docker build` with no --build-arg and so resolved AIRFLOW_BASE_IMAGE to a tag that is never published. Notable consequences: - celery and fab are now declared in apps/elt/pyproject.toml. Both are required by compose.airflow.yaml (CeleryExecutor, FabAuthManager) but were only ever present because the official image preinstalls 27 providers. - Ubuntu 26.04 packages python3.14 only, so uv fetches the 3.13 interpreter the workspace pins. The four python pins are unchanged. - /etc/passwd is made group-writable, as the official image does, so the entrypoint can register the arbitrary AIRFLOW_UID compose runs as. Without it getpass.getuser() fails and every airflow command dies. - pip is installed in the venv because uv omits it and the entrypoint shells out to it for _PIP_ADDITIONAL_REQUIREMENTS. Verified on the full compose stack: all airflow services healthy, staging_dag ingesting a GeoJSON over HTTP into PostGIS through ogr2ogr (2 rows, correct geometry and inferred types), Parquet read and write, and _PIP_ADDITIONAL_REQUIREMENTS installing a package absent from the lock. --- .../workflows/build-airflow-trixie-image.yml | 41 - Makefile | 21 +- apps/elt/pyproject.toml | 6 + apps/elt/uv.lock | 570 +++- docker/Dockerfile.airflow | 187 +- docker/airflow-base/Dockerfile | 2293 ----------------- docker/airflow-base/README.md | 56 - .../scripts/docker/keys/mariadb.asc | 104 - .../scripts/docker/keys/microsoft.asc | 42 - .../scripts/docker/keys/postgres.asc | 77 - .../scripts/docker/keys/python-3.10.asc | 0 docker/compose.airflow.yaml | 9 +- docs/technical_guides/configuration/elt.en.md | 7 +- 13 files changed, 677 insertions(+), 2736 deletions(-) delete mode 100644 .github/workflows/build-airflow-trixie-image.yml delete mode 100644 docker/airflow-base/Dockerfile delete mode 100644 docker/airflow-base/README.md delete mode 100644 docker/airflow-base/scripts/docker/keys/mariadb.asc delete mode 100644 docker/airflow-base/scripts/docker/keys/microsoft.asc delete mode 100644 docker/airflow-base/scripts/docker/keys/postgres.asc delete mode 100644 docker/airflow-base/scripts/docker/keys/python-3.10.asc diff --git a/.github/workflows/build-airflow-trixie-image.yml b/.github/workflows/build-airflow-trixie-image.yml deleted file mode 100644 index 8a2a37f3..00000000 --- a/.github/workflows/build-airflow-trixie-image.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: build-airflow-base-image.yml -on: - workflow_dispatch: - push: - paths: - - 'docker/airflow-base/**' - -jobs: - build-airflow: - runs-on: ubuntu-latest - name: Build & push airflow base - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Getting image tag - id: version - run: echo "VERSION=$(echo $GITHUB_REF | cut -d / -f 3)" >> $GITHUB_OUTPUT - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build airflow-base - run: | - make build-airflow-base - - - name: "Log in to docker.io" - if: github.repository == 'georchestra/datafeeder' - uses: docker/login-action@v3 - with: - username: '${{ secrets.DOCKER_HUB_USERNAME }}' - password: '${{ secrets.DOCKER_HUB_PASSWORD }}' - - - name: Push airflow - run: docker push georchestra/airflow-base:latest - - - name: Push release - if: github.ref_type == 'tag' - run: | - docker tag georchestra/airflow-base:latest georchestra/airflow-base:${{ steps.version.outputs.VERSION }} - docker push georchestra/airflow-base:${{ steps.version.outputs.VERSION }} diff --git a/Makefile b/Makefile index 5383d952..33ff757d 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,8 @@ # Display help message by default default: help -# Apache Airflow base image (built locally on Debian Trixie from the official -# Airflow Dockerfile, since apache/airflow only ships bookworm based images). AIRFLOW_VERSION ?= 3.2.2 -AIRFLOW_PYTHON_VERSION ?= 3.13.5 -AIRFLOW_BASE_IMAGE ?= georchestra/airflow-base:$(AIRFLOW_VERSION)-trixie export AIRFLOW_VERSION -export AIRFLOW_BASE_IMAGE help: ## Display this help message @echo "Usage: make " @@ -36,26 +31,12 @@ test-backend-coverage: install-python ## Run backend tests with coverage report build-libs: install-python ## Build all shared libraries uv build libs/data_manipulation -up: build-libs build-airflow-base ## Start all services including Airflow, GeoServer and GeoNetwork using Docker Compose +up: build-libs ## Start all services including Airflow, GeoServer and GeoNetwork using Docker Compose docker compose --profile airflow up -d --wait --build up-no-airflow: build-libs ## Start all services including GeoServer and GeoNetwork using Docker Compose (no Airflow, replaced with the local executor) docker compose --profile local-executor up -d --wait --build -build-airflow-base: ## Build the Debian Trixie based Apache Airflow base image (from the official Dockerfile) - @if [ -z "$$(docker images -q $(AIRFLOW_BASE_IMAGE))" ]; then \ - echo "Building $(AIRFLOW_BASE_IMAGE) (Airflow $(AIRFLOW_VERSION), Python $(AIRFLOW_PYTHON_VERSION) on debian:trixie-slim)..."; \ - docker build \ - --build-arg BASE_IMAGE=debian:trixie-slim \ - --build-arg AIRFLOW_VERSION=$(AIRFLOW_VERSION) \ - --build-arg AIRFLOW_PYTHON_VERSION=$(AIRFLOW_PYTHON_VERSION) \ - -t $(AIRFLOW_BASE_IMAGE) \ - docker/airflow-base; \ - docker tag $(AIRFLOW_BASE_IMAGE) georchestra/airflow-base:latest; \ - else \ - echo "$(AIRFLOW_BASE_IMAGE) already present, skipping (run 'docker rmi $(AIRFLOW_BASE_IMAGE)' to rebuild)."; \ - fi - down: ## Stop all services using Docker Compose docker compose --profile airflow down diff --git a/apps/elt/pyproject.toml b/apps/elt/pyproject.toml index 56267c2a..a694ae31 100644 --- a/apps/elt/pyproject.toml +++ b/apps/elt/pyproject.toml @@ -15,6 +15,12 @@ dependencies = [ "data_manipulation", "apache-airflow==3.2.2", # keep in sync with AIRFLOW_VERSION in docker/Dockerfile.airflow "apache-airflow-providers-postgres==6.7.0", + # Required by docker/compose.airflow.yaml, which sets CeleryExecutor and + # FabAuthManager. The apache/airflow image preinstalls both, but + # docker/Dockerfile.airflow builds on the GDAL image and installs only what + # this lock declares. + "apache-airflow-providers-celery==3.20.0", + "apache-airflow-providers-fab==3.6.4", ] [dependency-groups] diff --git a/apps/elt/uv.lock b/apps/elt/uv.lock index 9b17e7a7..8cead316 100644 --- a/apps/elt/uv.lock +++ b/apps/elt/uv.lock @@ -52,6 +52,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, ] +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -170,17 +182,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/63/8cb6a31954f40517fdd905113e0fef0fad46275c1b4c128b7bc916933768/apache_airflow_core-3.2.2-py3-none-any.whl", hash = "sha256:877be429d59193b5e9aab1ae69f44066134bf483c633e9f7c59a9220ebfcc013", size = 6102519, upload-time = "2026-05-29T05:20:08.257Z" }, ] +[[package]] +name = "apache-airflow-providers-celery" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apache-airflow" }, + { name = "apache-airflow-providers-common-compat" }, + { name = "celery", extra = ["redis"] }, + { name = "flower" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/98/2d30c85797c1f5a27f5343489d5d009d2d18b7617c01968b5587b3bf28c6/apache_airflow_providers_celery-3.20.0.tar.gz", hash = "sha256:18c224bf4e0f8373b2b50fc0f1d77261c5c916000f2a86e9d0e192e7917fb667", size = 170107, upload-time = "2026-05-23T12:32:20.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/5c/308c55020763efe553eaf7048ba51412666cda34a1cb8b98447c18838385/apache_airflow_providers_celery-3.20.0-py3-none-any.whl", hash = "sha256:99b783948e372133e7affcbbed1b74f837ae38938a2f5df8d125cf691c91aa39", size = 46905, upload-time = "2026-05-23T12:31:15.183Z" }, +] + [[package]] name = "apache-airflow-providers-common-compat" -version = "1.14.3" +version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "apache-airflow" }, { name = "asgiref" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/99/3852e08d1eecf36e152f57ce69d46fea95a903748b5d4d200b915efcb574/apache_airflow_providers_common_compat-1.14.3.tar.gz", hash = "sha256:1ecba0c30b4ac2c40983e9aa627927576f49e8d466fcabdf464d1f7c6e8c54f7", size = 39892, upload-time = "2026-04-13T23:21:11.702Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/ab/c2c58f105e16ba2e75371121e72d657189af32fd2bee0b505bc82bc8fbe6/apache_airflow_providers_common_compat-1.18.0.tar.gz", hash = "sha256:30dbdd9a2bd931469b32c180792630f5e6b84e0e908dfc18da51b4beb7dd521f", size = 43604, upload-time = "2026-08-08T03:17:07.126Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/59/eea9f8d9e66597842e0739854b7c841423a2589c2a3aeaec5f83151aded8/apache_airflow_providers_common_compat-1.14.3-py3-none-any.whl", hash = "sha256:269f48d21ff275d02c7db617d9e477aee6ad8eab39690f18b7342f1f3eba073b", size = 42396, upload-time = "2026-04-13T23:21:04.302Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cf/91dbaa7604f381f8e6d5cb94f6692713ee8b615a16b53563154a61b4c2eb/apache_airflow_providers_common_compat-1.18.0-py3-none-any.whl", hash = "sha256:dcdeda169e5771262969d3b7df2f9eca39c2037977e83560d75de2f0ea3d5787", size = 45013, upload-time = "2026-08-08T03:16:03.389Z" }, ] [[package]] @@ -212,6 +239,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/9f/4e9d0fcdb837d917817464e0eba596f3f8c2e7207f68dafca11261ae8ef3/apache_airflow_providers_common_sql-1.36.0-py3-none-any.whl", hash = "sha256:2e6e3c61cfdc391bf71eb783c1b785e7bfde3bd2d638c632fea4d438a6e2a240", size = 92533, upload-time = "2026-05-11T15:44:28.995Z" }, ] +[[package]] +name = "apache-airflow-providers-fab" +version = "3.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apache-airflow" }, + { name = "apache-airflow-providers-common-compat" }, + { name = "blinker" }, + { name = "cachetools" }, + { name = "flask" }, + { name = "flask-appbuilder" }, + { name = "flask-limiter" }, + { name = "flask-login" }, + { name = "flask-session" }, + { name = "flask-sqlalchemy" }, + { name = "flask-wtf" }, + { name = "jmespath" }, + { name = "marshmallow" }, + { name = "msgpack" }, + { name = "pyjwt" }, + { name = "werkzeug" }, + { name = "wtforms" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/8e/8f83f27721fe562d0267d1464bce958c84879cc1f15dcc7f7c2db730da13/apache_airflow_providers_fab-3.6.4.tar.gz", hash = "sha256:9929465a448726fb38aff47344529f36c10ddc41efb0187ad223ad66cc19afb3", size = 833913, upload-time = "2026-05-23T12:32:32.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/35/16b0ed3c463eb41bf772208e6c671512cc794348007e73ee6c08b1a0c72a/apache_airflow_providers_fab-3.6.4-py3-none-any.whl", hash = "sha256:8cc956cec46e27b49d7429c0e75fb2df5ba649e4294f7e696ae8c43b703f8378", size = 606521, upload-time = "2026-05-23T12:31:30.332Z" }, +] + [[package]] name = "apache-airflow-providers-postgres" version = "6.7.0" @@ -289,6 +344,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/ca/478018625c726131f9ea774eb839642ebeba09588792a56d5f7cd7f4e301/apache_airflow_task_sdk-1.2.2-py3-none-any.whl", hash = "sha256:709552227b8139b1264413fdc4f0ef3034dd0519c8adeaffd0c9c908a608e6c5", size = 492829, upload-time = "2026-05-29T05:20:51.182Z" }, ] +[[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, upload-time = "2026-03-06T21:48:40.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/88/e149b20246c4689e7d27163e4e3bb8946ef31617cfb3b9c427813483fe5b/apispec-6.10.0-py3-none-any.whl", hash = "sha256:8ff23e0de9a0ceb62ff70047241126315bd17b8d0565a567934c0156f4ddbb43", size = 31313, upload-time = "2026-03-06T21:48:39.404Z" }, +] + +[package.optional-dependencies] +yaml = [ + { name = "pyyaml" }, +] + [[package]] name = "argcomplete" version = "3.6.3" @@ -341,6 +413,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "billiard" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "cachelib" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/f4/b20875916b83f68775093554ce2544b12255396ba69abd93d8903cce0feb/cachelib-0.17.0.tar.gz", hash = "sha256:f3c7dc8d3c1132ab699681ffdf8a52d341d9425ac1401c538cf0b1d87b1677c8", size = 135529, upload-time = "2026-08-24T00:40:51.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/87/9110494f2816d3f2907ac9a0a0a5387f34bc4fa9755721ad09f0a2c99e9b/cachelib-0.17.0-py3-none-any.whl", hash = "sha256:f83909b6f78741c3a5d76d292d13bf24964ffb13e00ea1d18f92e20599766ce0", size = 28221, upload-time = "2026-08-24T00:40:50.237Z" }, +] + [[package]] name = "cachetools" version = "7.1.4" @@ -367,6 +466,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/c4/efee5781dc8b3a50fd876d413cad6db6ee69e1f21d5a07ca469cec03cd22/cadwyn-7.0.0-py3-none-any.whl", hash = "sha256:727d3c444ae992bb2a238246d13f173e4802c92e1c901458975549e6f0522560", size = 61194, upload-time = "2026-06-06T16:34:37.768Z" }, ] +[[package]] +name = "celery" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "tzlocal" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235, upload-time = "2026-03-26T12:14:49.491Z" }, +] + +[package.optional-dependencies] +redis = [ + { name = "kombu", extra = ["redis"] }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -451,6 +575,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -580,6 +741,8 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "apache-airflow" }, + { name = "apache-airflow-providers-celery" }, + { name = "apache-airflow-providers-fab" }, { name = "apache-airflow-providers-postgres" }, { name = "data-manipulation" }, ] @@ -593,6 +756,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "apache-airflow", specifier = "==3.2.2" }, + { name = "apache-airflow-providers-celery", specifier = "==3.20.0" }, + { name = "apache-airflow-providers-fab", specifier = "==3.6.4" }, { name = "apache-airflow-providers-postgres", specifier = "==6.7.0" }, { name = "data-manipulation", editable = "../../libs/data_manipulation" }, ] @@ -693,6 +858,169 @@ standard-no-fastapi-cloud-cli = [ { name = "uvicorn", extra = ["standard"] }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-appbuilder" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apispec", extra = ["yaml"] }, + { name = "click" }, + { name = "colorama" }, + { name = "email-validator" }, + { name = "flask" }, + { name = "flask-babel" }, + { name = "flask-jwt-extended" }, + { name = "flask-limiter" }, + { name = "flask-login" }, + { name = "flask-sqlalchemy" }, + { name = "flask-wtf" }, + { name = "jsonschema" }, + { name = "marshmallow" }, + { name = "marshmallow-sqlalchemy" }, + { name = "prison" }, + { name = "pyjwt" }, + { name = "python-dateutil" }, + { name = "sqlalchemy" }, + { name = "sqlalchemy-utils" }, + { name = "werkzeug" }, + { name = "wtforms" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b2/ad1784b3393cda84fd68b90689b1d6db037c13a62a9726ef639068bf8a75/flask_appbuilder-5.2.1.tar.gz", hash = "sha256:a5f6f34aa4ae0092a9eeb5051dc210869d9360429a429b0be88416c2616e1281", size = 7077970, upload-time = "2026-04-09T11:06:35.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/af/bdfd96170b1159ddc0e8424dfebbbac93d7270a1a749b0857fdaf384f027/flask_appbuilder-5.2.1-py3-none-any.whl", hash = "sha256:c5a134870fc0b780ac8d9de15fea8c470613ebe44feeddf01a2c18d2c066e144", size = 2217302, upload-time = "2026-04-09T11:06:32.891Z" }, +] + +[[package]] +name = "flask-babel" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "flask" }, + { name = "jinja2" }, + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/1a/4c65e3b90bda699a637bfb7fb96818b0a9bbff7636ea91aade67f6020a31/flask_babel-4.0.0.tar.gz", hash = "sha256:dbeab4027a3f4a87678a11686496e98e1492eb793cbdd77ab50f4e9a2602a593", size = 10178, upload-time = "2023-10-02T01:10:49.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/c2/e0ab5abe37882e118482884f2ec660cd06da644ddfbceccf5f88f546b574/flask_babel-4.0.0-py3-none-any.whl", hash = "sha256:638194cf91f8b301380f36d70e2034c77ee25b98cb5d80a1626820df9a6d4625", size = 9602, upload-time = "2023-10-02T01:10:48.58Z" }, +] + +[[package]] +name = "flask-jwt-extended" +version = "4.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "pyjwt" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/bf/75189cf38cd391dddeb097001be3bc9ec24a8cae5a5a3698cd0a3fcaa182/flask_jwt_extended-4.7.4.tar.gz", hash = "sha256:78fd0f460317facf3a0084a6457ffaf2f1dda9eefbd576f94cea35b0eadd5531", size = 34672, upload-time = "2026-05-13T15:23:17.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/38/547a19f8ed0460e8c67c5b9e56ad72002fb06a1862fb786ef071ff03b9df/flask_jwt_extended-4.7.4-py2.py3-none-any.whl", hash = "sha256:daad1981117f4972d63c363d013f290de307aad781a935921b603b714817393c", size = 22699, upload-time = "2026-05-13T15:23:16.503Z" }, +] + +[[package]] +name = "flask-limiter" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "limits" }, + { name = "ordered-set" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/75/92b237dd4f6e19196bc73007fff288ab1d4c64242603f3c401ff8fc58a42/flask_limiter-3.12.tar.gz", hash = "sha256:f9e3e3d0c4acd0d1ffbfa729e17198dd1042f4d23c130ae160044fc930e21300", size = 303162, upload-time = "2025-03-15T02:23:10.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/ba/40dafa278ee6a4300179d2bf59a1aa415165c26f74cfa17462132996186b/flask_limiter-3.12-py3-none-any.whl", hash = "sha256:b94c9e9584df98209542686947cf647f1ede35ed7e4ab564934a2bb9ed46b143", size = 28490, upload-time = "2025-03-15T02:23:08.919Z" }, +] + +[[package]] +name = "flask-login" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" }, +] + +[[package]] +name = "flask-session" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachelib" }, + { name = "flask" }, + { name = "msgspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/d7/0ba4180513abe28eadc208123c76f9f09e290d5939fb2eb68323b9733354/flask_session-0.8.0.tar.gz", hash = "sha256:20e045eb01103694e70be4a49f3a80dbb1b57296a22dc6f44bbf3f83ef0742ff", size = 940269, upload-time = "2024-03-26T07:56:13.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/1b/f085ceebb825d1cfaf078852b67cd248a33af2905f40ba9860cc006d966b/flask_session-0.8.0-py3-none-any.whl", hash = "sha256:5dae6e9ddab334f8dc4dea4305af37851f4e7dc0f484caf3351184001195e3b7", size = 24410, upload-time = "2024-03-26T07:56:11.377Z" }, +] + +[[package]] +name = "flask-sqlalchemy" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" }, +] + +[[package]] +name = "flask-wtf" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "itsdangerous" }, + { name = "wtforms" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/f1/605a56d4ea217b307f3e6f4d663e0351253d85d841edc93ba559f0648e19/flask_wtf-1.3.0.tar.gz", hash = "sha256:61d5dabc50c3df885c297dcbd80810443a5d632106c8a69cab8ce740f0cdd7cc", size = 50414, upload-time = "2026-04-23T07:41:55.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/d2/97adf2ec7af95522573e6dd5493ee84792d0fbfb2def010c4a581b8d6e5e/flask_wtf-1.3.0-py3-none-any.whl", hash = "sha256:dc5e3a4ce97f75c47bf6c1c72ad2c3b7bdf579a2ed13aebcc5d3d81fe2571160", size = 13959, upload-time = "2026-04-23T07:41:53.828Z" }, +] + +[[package]] +name = "flower" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "celery" }, + { name = "humanize" }, + { name = "prometheus-client" }, + { name = "pytz" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/9f/e3061a153ba1928a96cf1a431449411ff4a9411465d1554fdcd0feb8d239/flower-2.1.0.tar.gz", hash = "sha256:ece79fd190bfd198947e30470c4b26a6d5df1861d54309430dd926ed516302ff", size = 3486971, upload-time = "2026-08-16T07:36:18.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/87/cd70fe77cb5ad4a79eaebe7d86d15efd8b1d950f8d0ab99470a4fd21c9a8/flower-2.1.0-py2.py3-none-any.whl", hash = "sha256:2f433aaee3efeba5b844b2ab7371f16058d892de76156b77e6d09de03a4095bc", size = 404618, upload-time = "2026-08-16T07:36:16.627Z" }, +] + [[package]] name = "fsspec" version = "2026.4.0" @@ -845,6 +1173,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "humanize" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/ea/13a1ef3c12d12662905801495283530251918b70d62d368f1d2e0272c70d/humanize-4.16.0.tar.gz", hash = "sha256:7dc2244a2f84a4bfb1d36c37bac80cd78e35cdc5c119206d87b018e1445f3a3f", size = 89515, upload-time = "2026-06-30T16:17:29.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/aa/0b7365d30fed43e7a3449aba1fe20a0a7174d9cf13e282af4e69ac825441/humanize-4.16.0-py3-none-any.whl", hash = "sha256:353eb2f34c09d098b2880eee8bef21832eae6d174f48c5762fff7e5fcb74d01d", size = 137209, upload-time = "2026-06-30T16:17:28.36Z" }, +] + [[package]] name = "idna" version = "3.14" @@ -896,6 +1233,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -923,6 +1269,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "kombu" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, +] + +[package.optional-dependencies] +redis = [ + { name = "redis" }, +] + [[package]] name = "lazy-object-proxy" version = "1.12.0" @@ -970,6 +1336,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, ] +[[package]] +name = "limits" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, +] + [[package]] name = "linkify-it-py" version = "2.1.0" @@ -1071,6 +1451,28 @@ wheels = [ { 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" }, ] +[[package]] +name = "marshmallow" +version = "4.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/d7/611e68d57e6a903c29cb33b5afec0f93b4baecacc6c6c62e33cde9eb9dcb/marshmallow-4.3.1.tar.gz", hash = "sha256:fb6b8048af08d4ab061610d5b7d3696a7e4c95337dbda880edb9f95812cabc20", size = 218308, upload-time = "2026-08-08T14:27:29.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/57/4526ca0e214a3d158690e3393f6d547a2f8070f88d6388ab21d5b0aac8a1/marshmallow-4.3.1-py3-none-any.whl", hash = "sha256:e65accfbe277546df92ed7996a678c90e063e9a7c2a2f5e03f7d0b90e3768c42", size = 49219, upload-time = "2026-08-08T14:27:28.078Z" }, +] + +[[package]] +name = "marshmallow-sqlalchemy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/fe/247c297809e64116f766716632adbc3f4cd06f376f56dc15bb92f170d247/marshmallow_sqlalchemy-1.5.0.tar.gz", hash = "sha256:e51192c204770645a2fab0d72f44f8789272eef75951f84b1608d6b4b0bfe0e6", size = 51349, upload-time = "2026-04-01T23:21:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/b7/407c44dbd77a7670b7be6b7fedbe5329348fe00b4e62dae0f5c83a9aeafa/marshmallow_sqlalchemy-1.5.0-py3-none-any.whl", hash = "sha256:3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258", size = 16582, upload-time = "2026-04-01T23:21:02.376Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1101,6 +1503,25 @@ wheels = [ { 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]] +name = "msgpack" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/ea2100ec54d30c46ee9dba10a3bfb79b655e96c6df237238a3234c75869b/msgpack-1.2.2.tar.gz", hash = "sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a", size = 187025, upload-time = "2026-08-27T10:03:47.793Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/eb/42f31c5a48811787ff59a9869721f70a49654d65ab6c455f4463c39b044e/msgpack-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533", size = 83911, upload-time = "2026-08-27T10:02:24.06Z" }, + { url = "https://files.pythonhosted.org/packages/33/54/10c6c16ddba8a5112e3680176b838e3694e4aad7284f9daa6d6d70d98817/msgpack-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34", size = 83734, upload-time = "2026-08-27T10:02:25.613Z" }, + { url = "https://files.pythonhosted.org/packages/d7/75/35823e4419df8792191b2a17ae3fe71b41d02c162b2c491c94d1a87f0caa/msgpack-1.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc", size = 405635, upload-time = "2026-08-27T10:02:27.012Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/6592e4064619b04f2dd0054c5fa13e37e3d55eb26044483d871fadb2f46b/msgpack-1.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9", size = 417332, upload-time = "2026-08-27T10:02:28.776Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a1/b21c6818a545e9a4a976ac954a5c250eecde9a02e0ec82f415473dab1324/msgpack-1.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b", size = 374378, upload-time = "2026-08-27T10:02:30.678Z" }, + { url = "https://files.pythonhosted.org/packages/03/8b/7ada15c7b64151d6dbb562d1b091520efb2c37acf2403b1d4ae13797b27d/msgpack-1.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b", size = 395809, upload-time = "2026-08-27T10:02:32.322Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f7/96283e50f7020df4dfeacc55612b7a210c8cdf0dda48bc262f1f9b3e4c49/msgpack-1.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5", size = 373495, upload-time = "2026-08-27T10:02:33.832Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fe/1548dede9d9ca482f2d424a2e110a9705d4e02627a16b8bc8d10ce0208a2/msgpack-1.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab", size = 414360, upload-time = "2026-08-27T10:02:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/4419b8f86c219174b1fb8bbd7faaf84a548935f7b1916d028401b9433417/msgpack-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd", size = 65196, upload-time = "2026-08-27T10:02:37.007Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f8/593f5caf0dacab41cde1564c5f0419e61af55ec9628006205e8fd5eb5e03/msgpack-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f", size = 72203, upload-time = "2026-08-27T10:02:38.28Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/c6ef92046b4a2bbb9d3aa0cb581cbf4a4051afccf6e5fb301a1bd3086f39/msgpack-1.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896", size = 65435, upload-time = "2026-08-27T10:02:39.534Z" }, +] + [[package]] name = "msgspec" version = "0.21.1" @@ -1248,6 +1669,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, ] +[[package]] +name = "ordered-set" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -1356,6 +1786,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/99/4aefb693b0f52783fab496492f4444a75137312c386eb7c3320f1b0a1602/poethepoet-0.45.0-py3-none-any.whl", hash = "sha256:8e25f6e834ecf25fe2ddca676a4e0207eeb2e19def0a8709fc5c7f18e86cd68c", size = 123920, upload-time = "2026-04-28T21:04:57.66Z" }, ] +[[package]] +name = "prison" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/65/4456caa4e9bbd1d4d4b5eecaea41bb2cd31efe0e7e423c7a9ad8e2be75ea/prison-0.2.1.tar.gz", hash = "sha256:e6cd724044afcb1a8a69340cad2f1e3151a5839fd3a8027fd1357571e797c599", size = 12040, upload-time = "2021-08-26T18:58:48.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/bd/e55e14cd213174100be0353824f2add41e8996c6f32081888897e8ec48b5/prison-0.2.1-py2.py3-none-any.whl", hash = "sha256:f90bab63fca497aa0819a852f64fb21a4e181ed9f6114deaa5dc04001a7555c5", size = 5794, upload-time = "2021-08-26T18:58:46.254Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + [[package]] name = "protobuf" version = "6.33.6" @@ -1628,6 +2091,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, ] +[[package]] +name = "pytz" +version = "2026.3.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1670,6 +2142,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, ] +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -1885,6 +2366,18 @@ asyncio = [ { name = "greenlet" }, ] +[[package]] +name = "sqlalchemy-utils" +version = "0.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/7d/eb9565b6a49426552a5bf5c57e7c239c506dc0e4e5315aec6d1e8241dc7c/sqlalchemy_utils-0.42.1.tar.gz", hash = "sha256:881f9cd9e5044dc8f827bccb0425ce2e55490ce44fc0bb848c55cc8ee44cc02e", size = 130789, upload-time = "2025-12-13T03:14:13.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/25/7400c18c3ee97914cc99c90007795c00a4ec5b60c853b49db7ba24d11179/sqlalchemy_utils-0.42.1-py3-none-any.whl", hash = "sha256:243cfe1b3a1dae3c74118ae633f1d1e0ed8c787387bc33e556e37c990594ac80", size = 91761, upload-time = "2025-12-13T03:14:15.014Z" }, +] + [[package]] name = "sqlparse" version = "0.5.5" @@ -1963,6 +2456,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, ] +[[package]] +name = "tornado" +version = "6.5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" }, + { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" }, + { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" }, +] + [[package]] name = "typer" version = "0.25.1" @@ -2008,6 +2518,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] +[[package]] +name = "tzlocal" +version = "5.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, +] + [[package]] name = "uc-micro-py" version = "2.0.0" @@ -2086,6 +2608,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, ] +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + [[package]] name = "watchfiles" version = "1.1.1" @@ -2120,6 +2651,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, ] +[[package]] +name = "wcwidth" +version = "0.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" }, +] + [[package]] name = "websockets" version = "16.0" @@ -2138,6 +2678,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + [[package]] name = "wirerope" version = "1.0.0" @@ -2181,6 +2733,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ] +[[package]] +name = "wtforms" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/91/ed9b517da898e3fb747566aa3c12a734bd64ea7449a0d25ec74ce8f8b8eb/wtforms-3.2.2.tar.gz", hash = "sha256:7b00c73f8670f35d4edb0293dcd81b980528bee72fd662b182aaba27ae570b93", size = 139583, upload-time = "2026-05-03T05:53:44.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/76/bb225c8300f3a0ba28e01df51419c6c9574a297c43d71b29048e03b65deb/wtforms-3.2.2-py3-none-any.whl", hash = "sha256:72b90d5d921bd3119252069cf0301e9c13915f9e52792652bc91c5dda4b79e56", size = 158656, upload-time = "2026-05-03T05:53:46.072Z" }, +] + [[package]] name = "xmltodict" version = "1.0.4" diff --git a/docker/Dockerfile.airflow b/docker/Dockerfile.airflow index f71bec32..82a40ce4 100644 --- a/docker/Dockerfile.airflow +++ b/docker/Dockerfile.airflow @@ -1,120 +1,126 @@ # syntax=docker/dockerfile:1 ARG AIRFLOW_VERSION=3.2.2 ARG UV_VERSION=0.9.15 - -# Debian Trixie based Apache Airflow image, built locally from the official -# Airflow Dockerfile (docker/airflow-base) since apache/airflow only ships -# bookworm based images. Build it with `make build-airflow-base`. -ARG AIRFLOW_BASE_IMAGE=georchestra/airflow-base:${AIRFLOW_VERSION} +ARG PYTHON_VERSION=3.13 + +# The GDAL image is the base rather than an add-on: the ELT shells out to ogr2ogr +# (see libs/data_manipulation/src/data_manipulation/ingestion.py), and GDAL >= 3.13 +# is not packaged by any Debian release. Building on apache/airflow instead means +# grafting conda-forge GDAL onto it, and that build links against the *system* +# libstdc++, requiring GLIBCXX_3.4.31 / CXXABI_1.3.15 (GCC 13+): on the bookworm +# based official image ogr2ogr installs fine but dies at exec time with "version +# `CXXABI_1.3.15' not found". Neither LD_LIBRARY_PATH nor ldconfig fixes it, since +# libstdc++ is a direct NEEDED entry of the ogr2ogr binary, resolved outside the +# conda prefix's RUNPATH. Starting from the GDAL image keeps glibc, libstdc++ and +# GDAL a coherent set, and airflow is then just a Python distribution to install. +# ubuntu-full (not ubuntu-small) ships the Parquet and PostgreSQL drivers the ELT +# needs: FileType.PARQUET in apps/backend/src/models/data_import.py, and +# `ogr2ogr -f PostgreSQL` which requires ogr_PG.so (core alone only has the +# write-only PGDUMP driver). +ARG GDAL_IMAGE=ghcr.io/osgeo/gdal:ubuntu-full-3.13.2 # Named stage for the uv binary so later `COPY --from=uv` works on BuildKit # versions that don't support variable expansion in `--from`. FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv +# Pulled only to lift /entrypoint and /clean-logs, not used as a build base. +# Reusing them avoids reimplementing the 313 lines compose.airflow.yaml relies on +# (db wait, migration, celery broker wait, uid/gid checks, admin user creation). +FROM apache/airflow:${AIRFLOW_VERSION}-python${PYTHON_VERSION} AS official + # ============================================================================ -# Builder stage: resolve elt deps from apps/elt/uv.lock (independent of the -# root workspace, which can't satisfy airflow's fastapi cap alongside backend) -# and build the data_manipulation wheel. +# Base stage: GDAL + airflow and the elt dependencies, shared by development +# and production. # ============================================================================ -FROM python:3.13-slim-trixie AS builder +FROM ${GDAL_IMAGE} AS base +ARG PYTHON_VERSION +ARG AIRFLOW_VERSION COPY --from=uv /uv /uvx /bin/ +# Dependencies come from apps/elt/uv.lock, which is resolved independently of the +# root workspace (that one can't satisfy airflow's fastapi cap alongside backend). +# UV_FROZEN guards against silent drift from the lock file; UV_NO_CACHE keeps the +# layer small since the cache is single-use. ENV UV_LINK_MODE=copy \ UV_COMPILE_BYTECODE=1 \ - UV_PYTHON_DOWNLOADS=never \ - UV_PYTHON=python3.13 \ UV_CACHE_DIR=/tmp/uv-cache \ UV_NO_CACHE=1 \ - UV_FROZEN=1 + UV_FROZEN=1 \ + UV_PYTHON_DOWNLOADS=manual \ + UV_PYTHON_INSTALL_DIR=/opt/python \ + UV_PYTHON=${PYTHON_VERSION} \ + VIRTUAL_ENV=/opt/venv \ + PATH=/opt/venv/bin:$PATH + +# The copied /entrypoint runs under `set -u` and reads these without defaults, +# so they are part of its contract, not just informative. AIRFLOW_USER_HOME_DIR +# also anchors the PYTHONPATH it exports for `pip install --user` packages, which +# is how _PIP_ADDITIONAL_REQUIREMENTS becomes importable. +ENV AIRFLOW_HOME=/opt/airflow \ + AIRFLOW_USER_HOME_DIR=/home/airflow \ + AIRFLOW_UID=50000 \ + AIRFLOW_VERSION=${AIRFLOW_VERSION} + +# git: uv resolves the geoservercloud dependency from a git URL. +# netcat-openbsd: the official entrypoint's wait_for_connection() calls nc. +# dumb-init: PID 1 for the airflow processes, as in the official image. +# curl: used by the healthchecks in docker/compose.airflow.yaml. +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends git netcat-openbsd dumb-init curl \ + && rm -rf /var/lib/apt/lists/* WORKDIR /src - COPY apps/elt/pyproject.toml apps/elt/uv.lock apps/elt/ COPY libs/data_manipulation ./libs/data_manipulation +# Ubuntu 26.04 (the GDAL image's base) only packages python3.14, so the +# interpreter matching the workspace pin is fetched as a python-build-standalone +# build. It lands outside /root, which is 0700 and would be unreadable once we +# drop to USER airflow. +RUN uv python install ${PYTHON_VERSION} \ + && chmod -R a+rX /opt/python +# Back to never once the interpreter is in place, so nothing downloads one at runtime. +ENV UV_PYTHON_DOWNLOADS=never + +# Airflow and every provider come from uv.lock: unlike apache/airflow, the GDAL +# base ships no preinstalled provider distribution to preserve, so a plain venv +# is enough and `uv pip install --system` is not needed. +# pip is installed explicitly because `uv venv` omits it by design, while the +# copied /entrypoint shells out to `pip install` for _PIP_ADDITIONAL_REQUIREMENTS. RUN cd apps/elt \ && uv export --no-dev --no-emit-workspace -o /tmp/requirements.txt \ - && uv build --wheel ../../libs/data_manipulation -o /tmp/wheels - -# ============================================================================ -# Base stage: Trixie based airflow image + uv, shared by development and -# production. The base image is built from the official Airflow Dockerfile with -# local patches (see docker/airflow-base and `make build-airflow-base`) on -# debian:trixie-slim, using the Python that Trixie packages — currently 3.13, -# matching the workspace's python pin. -# ============================================================================ -FROM ${AIRFLOW_BASE_IMAGE} AS base - -# Switch to root so we can install uv into /bin and run uv pip install --system -# against the airflow interpreter. Each leaf stage drops back to USER airflow, -# the non-root user (uid 50000, gid 0) baked into the apache/airflow image. -USER root -COPY --from=uv /uv /uvx /bin/ + && uv build --wheel ../../libs/data_manipulation -o /tmp/wheels \ + && uv venv ${VIRTUAL_ENV} \ + && uv pip install pip -r /tmp/requirements.txt \ + && rm -f /tmp/requirements.txt -# Same uv tuning as the builder stage. UV_FROZEN guards against silent drift -# from uv.lock; UV_NO_CACHE keeps the layer small since the cache is single-use. -# UV_PYTHON points at the airflow image's venv interpreter so `uv pip install -# --system` installs alongside the preinstalled airflow + provider distribution -# rather than failing to find a system python3.13. -ENV UV_LINK_MODE=copy \ - UV_COMPILE_BYTECODE=1 \ - UV_PYTHON_DOWNLOADS=never \ - UV_PYTHON=/home/airflow/.local/bin/python \ - UV_CACHE_DIR=/tmp/uv-cache \ - UV_NO_CACHE=1 \ - UV_FROZEN=1 - -# GDAL from conda-forge, which is the reason this image needs a Trixie base: no -# Debian release ships GDAL >= 3.13, and the conda-forge build links against the -# *system* libstdc++, requiring GLIBCXX_3.4.31 / CXXABI_1.3.15 (GCC 13+). On -# bookworm ogr2ogr installs fine but dies at exec time with "version -# `CXXABI_1.3.15' not found"; neither LD_LIBRARY_PATH nor ldconfig fixes it, -# because libstdc++ is a direct NEEDED entry of the ogr2ogr binary and is -# resolved outside the conda RUNPATH. -# GDAL goes into an isolated prefix and the CLI is exposed through small wrapper -# scripts (not bare symlinks) that put ${GDAL_PREFIX}/lib first in -# LD_LIBRARY_PATH, so conda's libproj/libicu/... win over any system copy the -# airflow worker may have in its environment. We deliberately keep -# ${GDAL_PREFIX}/bin off the global PATH so conda's python never shadows the -# airflow interpreter. -# conda-forge splits GDAL drivers into per-format packages on top of -# libgdal-core: libgdal-arrow-parquet provides the (Geo)Parquet driver and -# libgdal-pg provides the live PostgreSQL/PostGIS driver (ogr_PG.so) that the -# ELT uses to write into PostGIS via `ogr2ogr -f PostgreSQL`. Without it core -# only ships PGDUMP (write-only SQL dump), not the network driver. -ENV GDAL_PREFIX=/opt/gdal -RUN --mount=type=cache,target=/opt/conda-cache,sharing=locked \ - curl -Ls https://github.com/mamba-org/micromamba-releases/releases/latest/download/micromamba-linux-64 \ - -o /usr/local/bin/micromamba \ - && chmod +x /usr/local/bin/micromamba \ - && MAMBA_ROOT_PREFIX=/opt/conda-cache micromamba create -y -p ${GDAL_PREFIX} \ - -c conda-forge 'gdal>=3.12' libgdal-arrow-parquet libgdal-pg \ - && for b in ogr2ogr ogrinfo gdalinfo gdal_translate gdalwarp gdalsrsinfo; do \ - printf '#!/bin/sh\nexport LD_LIBRARY_PATH="%s/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"\nexec "%s/bin/%s" "$@"\n' \ - "${GDAL_PREFIX}" "${GDAL_PREFIX}" "$b" > /usr/local/bin/$b \ - && chmod +x /usr/local/bin/$b; \ - done \ - && find ${GDAL_PREFIX} -name '*.a' -delete \ - && rm -rf ${GDAL_PREFIX}/pkgs - -ENV GDAL_DATA=${GDAL_PREFIX}/share/gdal \ - PROJ_DATA=${GDAL_PREFIX}/share/proj +COPY --from=official /entrypoint /clean-logs / + +# uid 50000 / gid 0 is the airflow user the official image bakes in, and what +# compose.airflow.yaml and the entrypoint's uid checks expect. +# /etc/passwd is made group-writable, as in the official image: compose runs the +# containers as ${AIRFLOW_UID}, an arbitrary uid absent from /etc/passwd, and the +# entrypoint's create_system_user_if_missing() appends an entry for it. Without +# it getpass.getuser() raises "No username set in the environment" and every +# airflow command fails. +RUN useradd -u 50000 -g 0 -d ${AIRFLOW_USER_HOME_DIR} -m -s /bin/bash airflow \ + && chmod g+w /etc/passwd \ + && mkdir -p ${AIRFLOW_HOME}/dags ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/config \ + && chown -R airflow:0 ${AIRFLOW_HOME} ${AIRFLOW_USER_HOME_DIR} ${VIRTUAL_ENV} \ + && chmod -R g+rw ${AIRFLOW_HOME} ${AIRFLOW_USER_HOME_DIR} + +WORKDIR ${AIRFLOW_HOME} +ENTRYPOINT ["/usr/bin/dumb-init", "--", "/entrypoint"] +CMD [] # ============================================================================ # Development stage: deps only. libs/data_manipulation is bind-mounted under -# /opt/airflow/dags by compose.airflow.yaml for hot-reload, so we don't install -# the wheel here. +# /opt/airflow/dags by compose.airflow.yaml for hot-reload, so the wheel built +# in the base stage is deliberately not installed here. # ============================================================================ FROM base AS development -# Apply the resolved requirements set produced by the builder via uv export. -# --system targets the airflow image's interpreter rather than a fresh venv, -# preserving the preinstalled airflow + provider distribution. -COPY --from=builder /tmp/requirements.txt /tmp/requirements.txt -RUN uv pip install --system -r /tmp/requirements.txt \ - && rm -f /tmp/requirements.txt - USER airflow # ============================================================================ @@ -123,13 +129,8 @@ USER airflow # ============================================================================ FROM base AS production -# Install requirements and the lib wheel in a single layer so pip resolves them -# together and the intermediate files don't end up in the final image. -COPY --from=builder /tmp/requirements.txt /tmp/requirements.txt -COPY --from=builder /tmp/wheels/ /tmp/wheels/ -RUN uv pip install --system -r /tmp/requirements.txt /tmp/wheels/*.whl \ - && rm -rf /tmp/requirements.txt /tmp/wheels +RUN uv pip install /tmp/wheels/*.whl && rm -rf /tmp/wheels # DAGs are copied (not volume-mounted) so the production image is self-contained. -COPY --chown=airflow:0 apps/elt/dags/ /opt/airflow/dags/ +COPY --chown=airflow:0 apps/elt/dags/ ${AIRFLOW_HOME}/dags/ USER airflow diff --git a/docker/airflow-base/Dockerfile b/docker/airflow-base/Dockerfile deleted file mode 100644 index 023ecd9c..00000000 --- a/docker/airflow-base/Dockerfile +++ /dev/null @@ -1,2293 +0,0 @@ -# syntax=docker/dockerfile:1.4 -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -# THIS DOCKERFILE IS INTENDED FOR PRODUCTION USE AND DEPLOYMENT. -# NOTE! IT IS ALPHA-QUALITY FOR NOW - WE ARE IN A PROCESS OF TESTING IT -# -# -# This is a multi-segmented image. It actually contains two images: -# -# airflow-build-image - there all airflow dependencies can be installed (and -# built - for those dependencies that require -# build essentials). Airflow is installed there with -# ${HOME}/.local virtualenv which is also considered -# As --user folder by python when creating venv with -# --system-site-packages -# -# main - this is the actual production image that is much -# smaller because it does not contain all the build -# essentials. Instead the ${HOME}/.local folder -# is copied from the build-image - this way we have -# only result of installation and we do not need -# all the build essentials. This makes the image -# much smaller. -# -# Use the same builder frontend version for everyone -ARG AIRFLOW_EXTRAS="aiobotocore,amazon,async,celery,cncf-kubernetes,common-io,common-messaging,docker,elasticsearch,fab,ftp,git,google,google-auth,graphviz,grpc,hashicorp,http,ldap,microsoft-azure,mysql,odbc,openlineage,pandas,postgres,redis,sendgrid,sftp,slack,snowflake,ssh,statsd,uv" -ARG ADDITIONAL_AIRFLOW_EXTRAS="" -ARG ADDITIONAL_PYTHON_DEPS="" - -ARG AIRFLOW_HOME=/opt/airflow -ARG AIRFLOW_IMAGE_TYPE="prod" -ARG AIRFLOW_UID="50000" -ARG AIRFLOW_USER_HOME_DIR=/home/airflow - -# latest released version here -ARG AIRFLOW_VERSION="3.2.2" - -ARG BASE_IMAGE="debian:trixie-slim" -ARG AIRFLOW_PYTHON_VERSION="3.13.13" - -# PYTHON_LTO: Controls whether Python is built with Link-Time Optimization (LTO). -# -# Link-Time Optimization uses MD5 checksums during the compilation process to verify -# object files and intermediate representations. In FIPS-compliant environments, MD5 -# is blocked as it's not an approved cryptographic algorithm (see FIPS 140-2/140-3). -# This can cause Python builds with LTO to fail when FIPS mode is enabled. -# -# When building FIPS-compliant images, set this to "false" to disable LTO: -# docker build --build-arg PYTHON_LTO="false" ... -# -# Default: "true" (LTO enabled for better performance) -# -# Related: https://github.com/apache/airflow/issues/58337 -ARG PYTHON_LTO="true" - -# You can swap comments between those two args to test pip from the main version -# When you attempt to test if the version of `pip` from specified branch works for our builds -# Also use `force pip` label on your PR to swap all places we use `uv` to `pip` -ARG AIRFLOW_PIP_VERSION=26.1.2 -# ARG AIRFLOW_PIP_VERSION="git+https://github.com/pypa/pip.git@main" -ARG AIRFLOW_UV_VERSION=0.11.19 -ARG AIRFLOW_USE_UV="false" -ARG AIRFLOW_IMAGE_REPOSITORY="https://github.com/apache/airflow" -ARG AIRFLOW_IMAGE_README_URL="https://raw.githubusercontent.com/apache/airflow/main/docs/docker-stack/README.md" - -# By default we install latest airflow from PyPI so we do not need to copy sources of Airflow -# from the host - so we are using Dockerfile and copy it to /Dockerfile in target image -# because this is the only file we know exists locally. This way you can build the image in PyPI with -# **just** the Dockerfile and no need for any other files from Airflow repository. -# However, in case of breeze/development use we use latest sources and we override those -# SOURCES_FROM/TO with "." and "/opt/airflow" respectively - so that sources of Airflow (and all providers) -# are used to build the PROD image used in tests. -ARG AIRFLOW_SOURCES_FROM="Dockerfile" -ARG AIRFLOW_SOURCES_TO="/Dockerfile" - -# By default latest released version of airflow is installed (when empty) but this value can be overridden -# and we can install version according to specification (For example ==2.0.2 or <3.0.0). -ARG AIRFLOW_VERSION_SPECIFICATION="" - -# By default PIP has progress bar but you can disable it. -ARG PIP_PROGRESS_BAR="on" - -############################################################################################## -# This is the script image where we keep all inlined bash scripts needed in other segments -############################################################################################## -FROM scratch as scripts - -############################################################################################## -# Please DO NOT modify the inlined scripts manually. The content of those files will be -# replaced by prek automatically from the "scripts/docker/" folder. -# This is done in order to avoid problems with caching and file permissions and in order to -# make the PROD Dockerfile standalone -############################################################################################## - -# The content below is automatically copied from scripts/docker/install_os_dependencies.sh -COPY <<"EOF" /install_os_dependencies.sh -#!/usr/bin/env bash -set -euo pipefail - -if [[ "$#" != 1 ]]; then - echo - echo "ERROR! There should be 'runtime', 'ci' or 'dev' parameter passed as argument.". - echo - exit 1 -fi - -AIRFLOW_PYTHON_VERSION=${AIRFLOW_PYTHON_VERSION:-3.10.18} -PYTHON_LTO=${PYTHON_LTO:-true} -GOLANG_MAJOR_MINOR_VERSION=${GOLANG_MAJOR_MINOR_VERSION:-1.24.4} -RUSTUP_DEFAULT_TOOLCHAIN=${RUSTUP_DEFAULT_TOOLCHAIN:-stable} -RUSTUP_VERSION=${RUSTUP_VERSION:-1.29.0} -COSIGN_VERSION=${COSIGN_VERSION:-3.0.5} - -if [[ "${1}" == "runtime" ]]; then - INSTALLATION_TYPE="RUNTIME" -elif [[ "${1}" == "dev" ]]; then - INSTALLATION_TYPE="DEV" -elif [[ "${1}" == "ci" ]]; then - INSTALLATION_TYPE="CI" -else - echo - echo "ERROR! Wrong argument. Passed ${1} and it should be one of 'runtime', 'ci' or 'dev'.". - echo - exit 1 -fi - -function get_dev_apt_deps() { - if [[ "${DEV_APT_DEPS=}" == "" ]]; then - DEV_APT_DEPS="\ -apt-transport-https \ -apt-utils \ -build-essential \ -dirmngr \ -freetds-bin \ -freetds-dev \ -git \ -graphviz \ -graphviz-dev \ -krb5-user \ -ldap-utils \ -libbluetooth-dev \ -libbz2-dev \ -libc6-dev \ -libdb-dev \ -libev-dev \ -libev4 \ -libffi-dev \ -libgdbm-compat-dev \ -libgdbm-dev \ -libgeos-dev \ -libkrb5-dev \ -libldap2-dev \ -libleveldb-dev \ -libleveldb1d \ -liblzma-dev \ -libncurses-dev \ -libreadline-dev \ -libsasl2-2 \ -libsasl2-dev \ -libsasl2-modules \ -libsqlite3-dev \ -libssl-dev \ -libxmlsec1 \ -libxmlsec1-dev \ -libzstd-dev \ -locales \ -lsb-release \ -lzma \ -openssh-client \ -openssl \ -pkg-config \ -pkgconf \ -sasl2-bin \ -sqlite3 \ -sudo \ -tk-dev \ -unixodbc \ -unixodbc-dev \ -uuid-dev \ -wget \ -xz-utils \ -zlib1g-dev \ -" - export DEV_APT_DEPS - fi -} - -function get_runtime_apt_deps() { - local debian_version - local debian_version_apt_deps - # Get debian version without installing lsb_release - # shellcheck disable=SC1091 - debian_version=$(. /etc/os-release; printf '%s\n' "$VERSION_CODENAME";) - echo - echo "DEBIAN CODENAME: ${debian_version}" - echo - debian_version_apt_deps="\ -libffi8 \ -libldap2 \ -libssl3 \ -netcat-openbsd\ -" - echo - echo "APPLIED INSTALLATION CONFIGURATION FOR DEBIAN VERSION: ${debian_version}" - echo - if [[ "${RUNTIME_APT_DEPS=}" == "" ]]; then - RUNTIME_APT_DEPS="\ -${debian_version_apt_deps} \ -apt-transport-https \ -apt-utils \ -curl \ -dumb-init \ -freetds-bin \ -git \ -gnupg \ -iputils-ping \ -krb5-user \ -ldap-utils \ -libev4 \ -libgeos-dev \ -libsasl2-2 \ -libsasl2-modules \ -libxmlsec1 \ -locales \ -lsb-release \ -openssh-client \ -python3 \ -python3-pip \ -python3-venv \ -rsync \ -sasl2-bin \ -sqlite3 \ -sudo \ -unixodbc \ -wget\ -" - export RUNTIME_APT_DEPS - fi -} - -function install_docker_cli() { - apt-get update - apt-get install ca-certificates curl - install -m 0755 -d /etc/apt/keyrings - curl -fsSL --retry 3 --retry-delay 5 https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc - chmod a+r /etc/apt/keyrings/docker.asc - # shellcheck disable=SC1091 - echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \ - $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ - tee /etc/apt/sources.list.d/docker.list > /dev/null - apt-get update - apt-get install -y --no-install-recommends docker-ce-cli -} - -function install_debian_dev_dependencies() { - apt-get update - apt-get install -yqq --no-install-recommends apt-utils >/dev/null 2>&1 - apt-get install -y --no-install-recommends wget curl gnupg2 lsb-release ca-certificates - # shellcheck disable=SC2086 - export ${ADDITIONAL_DEV_APT_ENV?} - if [[ ${DEV_APT_COMMAND} != "" ]]; then - bash -o pipefail -o errexit -o nounset -o nolog -c "${DEV_APT_COMMAND}" - fi - if [[ ${ADDITIONAL_DEV_APT_COMMAND} != "" ]]; then - bash -o pipefail -o errexit -o nounset -o nolog -c "${ADDITIONAL_DEV_APT_COMMAND}" - fi - apt-get update - local debian_version - local debian_version_apt_deps - # Get debian version without installing lsb_release - # shellcheck disable=SC1091 - debian_version=$(. /etc/os-release; printf '%s\n' "$VERSION_CODENAME";) - echo - echo "DEBIAN CODENAME: ${debian_version}" - echo - # shellcheck disable=SC2086 - apt-get install -y --no-install-recommends ${DEV_APT_DEPS} -} - -function install_additional_dev_dependencies() { - if [[ "${ADDITIONAL_DEV_APT_DEPS=}" != "" ]]; then - # shellcheck disable=SC2086 - apt-get install -y --no-install-recommends ${ADDITIONAL_DEV_APT_DEPS} - fi -} - -function link_python() { - # link python binaries to /usr/local/bin and /usr/python/bin with and without 3 suffix - # Links in /usr/local/bin are needed for tools that expect python to be there - # Links in /usr/python/bin are needed for tools that are detecting home of python installation including - # lib/site-packages. The /usr/python/bin should be first in PATH in order to help with the last part. - for dst in pip3 python3 python3-config; do - src="$(echo "${dst}" | tr -d 3)" - echo "Linking ${dst} in /usr/local/bin and /usr/python/bin" - ln -sv "/usr/python/bin/${dst}" "/usr/local/bin/${dst}" - for dir in /usr/local/bin /usr/python/bin; do - if [[ ! -e "${dir}/${src}" ]]; then - echo "Creating ${src} - > ${dst} link in ${dir}" - ln -sv "${dir}/${dst}" "${dir}/${src}" - fi - done - done - for dst in /usr/python/lib/* - do - src="/usr/local/lib/$(basename "${dst}")" - if [[ -e "${src}" ]]; then - rm -rf "${src}" - fi - echo "Linking ${dst} to ${src}" - ln -sv "${dst}" "${src}" - done - ldconfig -} - -function install_debian_runtime_dependencies() { - apt-get update - apt-get install --no-install-recommends -yqq apt-utils >/dev/null 2>&1 - apt-get install -y --no-install-recommends wget curl gnupg2 lsb-release ca-certificates - # shellcheck disable=SC2086 - export ${ADDITIONAL_RUNTIME_APT_ENV?} - if [[ "${RUNTIME_APT_COMMAND}" != "" ]]; then - bash -o pipefail -o errexit -o nounset -o nolog -c "${RUNTIME_APT_COMMAND}" - fi - if [[ "${ADDITIONAL_RUNTIME_APT_COMMAND}" != "" ]]; then - bash -o pipefail -o errexit -o nounset -o nolog -c "${ADDITIONAL_RUNTIME_APT_COMMAND}" - fi - apt-get update - # shellcheck disable=SC2086 - apt-get install -y --no-install-recommends ${RUNTIME_APT_DEPS} ${ADDITIONAL_RUNTIME_APT_DEPS} - apt-get autoremove -yqq --purge - apt-get clean - link_python - rm -rf /var/lib/apt/lists/* /var/log/* -} - -function install_cosign() { - local arch - arch="$(dpkg --print-architecture)" - declare -A cosign_sha256s=( - # https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign_checksums.txt - [amd64]="db15cc99e6e4837daabab023742aaddc3841ce57f193d11b7c3e06c8003642b2" - [arm64]="d098f3168ae4b3aa70b4ca78947329b953272b487727d1722cb3cb098a1a20ab" - ) - local cosign_sha256="${cosign_sha256s[${arch}]}" - if [[ -z "${cosign_sha256}" ]]; then - echo "Unsupported architecture for cosign: ${arch}" - exit 1 - fi - curl -fsSL --retry 3 --retry-delay 5 \ - "https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-${arch}" \ - -o /tmp/cosign - echo "${cosign_sha256} /tmp/cosign" | sha256sum --check - chmod +x /tmp/cosign -} - -function install_python() { - # OPTION 2: use Debian's system Python instead of building it from source. - # Debian trixie already ships Python 3.13, so we install it from apt and - # recreate the /usr/python layout (via symlinks) that the rest of the image - # expects (PATH, LD_LIBRARY_PATH and the COPY into the final image). - echo - echo "Installing Python from Debian packages (no source build)..." - echo - apt-get update - apt-get install -y --no-install-recommends \ - python3 python3-dev python3-venv python3-pip libpython3-dev - # Debian marks the system Python as PEP 668 "externally-managed", which blocks - # `pip install` outside a venv. The original from-source build had no such marker, - # so remove it to keep the existing packaging-tools bootstrap working. - rm -f /usr/lib/python3.*/EXTERNALLY-MANAGED - local arch_triplet - arch_triplet="$(dpkg-architecture -qDEB_HOST_MULTIARCH)" - mkdir -p /usr/python/bin /usr/python/lib - for f in python3 python3-config pip3; do - [[ -e "/usr/bin/${f}" ]] && ln -sfv "/usr/bin/${f}" "/usr/python/bin/${f}" - done - ln -sfv /usr/bin/python3 /usr/python/bin/python - ln -sfv /usr/bin/pip3 /usr/python/bin/pip || true - ln -sfv /usr/bin/python3 /usr/local/bin/python - ln -sfv /usr/bin/pip3 /usr/local/bin/pip || true - for lib in /usr/lib/"${arch_triplet}"/libpython3*.so*; do - [[ -e "${lib}" ]] && ln -sfv "${lib}" "/usr/python/lib/$(basename "${lib}")" - done - ldconfig - return 0 - - # --- Original from-source Python build kept below (disabled by the early return above) --- - # If system python (3.11 in bookworm) is installed (via automatic installation of some dependencies for example), we need - # to fail and make sure that it is not there, because there can be strange interactions if we install - # newer version and system libraries are installed, because - # when you create a virtualenv part of the shared libraries of Python can be taken from the system - # Installation leading to weird errors when you want to install some modules - for example when you install ssl: - # /usr/python/lib/python3.11/lib-dynload/_ssl.cpython-311-aarch64-linux-gnu.so: undefined symbol: _PyModule_Add - if dpkg -l | grep '^ii' | grep '^ii libpython' >/dev/null; then - echo - echo "ERROR! System python is installed by one of the previous steps" - echo - installed_libpython=$(dpkg -l | awk '/^ii libpython3/{print $2; exit}') - echo "Please make sure that no python packages are installed by default. Displaying the reason why ${installed_libpython} is installed:" - echo - apt-get install -yqq aptitude >/dev/null - aptitude why "${installed_libpython}" - echo - exit 1 - else - echo - echo "GOOD! System python is not installed - OK" - echo - fi - wget --tries=3 --waitretry=5 -O python.tar.xz "https://www.python.org/ftp/python/${AIRFLOW_PYTHON_VERSION%%[a-z]*}/Python-${AIRFLOW_PYTHON_VERSION}.tar.xz" - local major_minor_version - major_minor_version="${AIRFLOW_PYTHON_VERSION%.*}" - local major minor - major="${major_minor_version%.*}" - minor="${major_minor_version#*.}" - echo "Verifying Python ${AIRFLOW_PYTHON_VERSION} (${major_minor_version})" - if [[ "${major}" -gt 3 ]] || [[ "${major}" -eq 3 && "${minor}" -ge 11 ]]; then - # Sigstore verification for Python >= 3.11 (PEP 761) - declare -A sigstore_identities=( - # https://peps.python.org/pep-0664/#release-manager-and-crew - [3.11]="pablogsal@python.org" - # https://peps.python.org/pep-0693/#release-manager-and-crew - [3.12]="thomas@python.org" - # https://peps.python.org/pep-0719/#release-manager-and-crew - [3.13]="thomas@python.org" - # https://peps.python.org/pep-0745/#release-manager-and-crew - [3.14]="hugo@python.org" - ) - declare -A sigstore_issuers=( - [3.11]="https://accounts.google.com" - [3.12]="https://accounts.google.com" - [3.13]="https://accounts.google.com" - [3.14]="https://github.com/login/oauth" - ) - wget --tries=3 --waitretry=5 -O python.tar.xz.sigstore \ - "https://www.python.org/ftp/python/${AIRFLOW_PYTHON_VERSION%%[a-z]*}/Python-${AIRFLOW_PYTHON_VERSION}.tar.xz.sigstore" - install_cosign - local identity="${sigstore_identities[${major_minor_version}]}" - local issuer="${sigstore_issuers[${major_minor_version}]}" - /tmp/cosign verify-blob \ - --bundle python.tar.xz.sigstore \ - --certificate-identity "${identity}" \ - --certificate-oidc-issuer "${issuer}" \ - python.tar.xz - rm -f python.tar.xz.sigstore /tmp/cosign - else - # PGP verification for Python 3.10 - declare -A keys=( - # gpg: key 64E628F8D684696D: public key "Pablo Galindo Salgado " imported - # https://peps.python.org/pep-0619/#release-manager-and-crew - [3.10]="A035C8C19219BA821ECEA86B64E628F8D684696D" - ) - wget --tries=3 --waitretry=5 -O python.tar.xz.asc \ - "https://www.python.org/ftp/python/${AIRFLOW_PYTHON_VERSION%%[a-z]*}/Python-${AIRFLOW_PYTHON_VERSION}.tar.xz.asc" - GNUPGHOME="$(mktemp -d)"; export GNUPGHOME - local gpg_key="${keys[${major_minor_version}]}" - echo "Using GPG key ${gpg_key}" - gpg --batch --import "/scripts/docker/keys/python-${major_minor_version}.asc" - gpg --batch --verify python.tar.xz.asc python.tar.xz - gpgconf --kill all - rm -rf "${GNUPGHOME}" python.tar.xz.asc - fi - mkdir -p /usr/src/python - tar --extract --directory /usr/src/python --strip-components=1 --file python.tar.xz - rm python.tar.xz - cd /usr/src/python - arch="$(dpkg --print-architecture)"; arch="${arch##*-}" - gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)" - EXTRA_CFLAGS="$(dpkg-buildflags --get CFLAGS)" - EXTRA_CFLAGS="${EXTRA_CFLAGS:-} -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer"; - LDFLAGS="$(dpkg-buildflags --get LDFLAGS)" - LDFLAGS="${LDFLAGS:--Wl},--strip-all" - # Link-Time Optimization (LTO) uses MD5 checksums for object file verification during - # compilation. In FIPS mode, MD5 is blocked as a non-approved algorithm, causing builds - # to fail. The PYTHON_LTO variable allows disabling LTO for FIPS-compliant builds. - # See: https://github.com/apache/airflow/issues/58337 - local lto_option="" - if [[ "${PYTHON_LTO:-true}" == "true" ]]; then - lto_option="--with-lto" - fi - local build_log - build_log=$(mktemp) - echo "Building Python ${AIRFLOW_PYTHON_VERSION} from source..." - if ! ( - ./configure --enable-optimizations --prefix=/usr/python/ --with-ensurepip --build="$gnuArch" \ - --enable-loadable-sqlite-extensions --enable-option-checking=fatal \ - --enable-shared ${lto_option} && \ - make -s -j "$(nproc)" "EXTRA_CFLAGS=${EXTRA_CFLAGS:-}" \ - "LDFLAGS=${LDFLAGS:--Wl},-rpath='\$\$ORIGIN/../lib'" python && \ - make -s -j "$(nproc)" install - ) > "${build_log}" 2>&1; then - echo - echo "ERROR! Python build failed. Build output:" - echo - cat "${build_log}" - rm -f "${build_log}" - exit 1 - fi - rm -f "${build_log}" - cd / - rm -rf /usr/src/python - find /usr/python -depth \ - \( \ - \( -type d -a \( -name test -o -name tests -o -name idle_test \) \) \ - -o \( -type f -a \( -name 'libpython*.a' \) \) \ - \) -exec rm -rf '{}' + - link_python -} - -function install_golang() { - curl --retry 3 --retry-delay 5 "https://dl.google.com/go/go${GOLANG_MAJOR_MINOR_VERSION}.linux-$(dpkg --print-architecture).tar.gz" -o "go${GOLANG_MAJOR_MINOR_VERSION}.linux.tar.gz" - rm -rf /usr/local/go && tar -C /usr/local -xzf go"${GOLANG_MAJOR_MINOR_VERSION}".linux.tar.gz -} - -function install_rustup() { - local arch - arch="$(dpkg --print-architecture)" - declare -A rustup_targets=( - [amd64]="x86_64-unknown-linux-gnu" - [arm64]="aarch64-unknown-linux-gnu" - ) - declare -A rustup_sha256s=( - # https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/{target}/rustup-init.sha256 - [amd64]="4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10" - [arm64]="9732d6c5e2a098d3521fca8145d826ae0aaa067ef2385ead08e6feac88fa5792" - ) - local target="${rustup_targets[${arch}]}" - local rustup_sha256="${rustup_sha256s[${arch}]}" - if [[ -z "${target}" ]]; then - echo "Unsupported architecture for rustup: ${arch}" - exit 1 - fi - curl --proto '=https' --tlsv1.2 -sSf --retry 3 --retry-delay 5 \ - "https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/${target}/rustup-init" \ - -o /tmp/rustup-init - echo "${rustup_sha256} /tmp/rustup-init" | sha256sum --check - chmod +x /tmp/rustup-init - /tmp/rustup-init -y --default-toolchain "${RUSTUP_DEFAULT_TOOLCHAIN}" - rm -f /tmp/rustup-init -} - -function apt_clean() { - apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false - rm -rf /var/lib/apt/lists/* /var/log/* -} - -if [[ "${INSTALLATION_TYPE}" == "RUNTIME" ]]; then - get_runtime_apt_deps - install_debian_runtime_dependencies - install_docker_cli - apt_clean -else - get_dev_apt_deps - install_debian_dev_dependencies - install_python - install_additional_dev_dependencies - install_rustup - if [[ "${INSTALLATION_TYPE}" == "CI" ]]; then - install_golang - fi - install_docker_cli - apt_clean -fi -EOF - -# The content below is automatically copied from scripts/docker/install_mysql.sh -COPY <<"EOF" /install_mysql.sh -#!/usr/bin/env bash -. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" - -set -euo pipefail - -common::get_colors -declare -a packages - -readonly MARIADB_LTS_VERSION="11.8" - -: "${INSTALL_MYSQL_CLIENT:?Should be true or false}" -: "${INSTALL_MYSQL_CLIENT_TYPE:-mariadb}" - -if [[ "${INSTALL_MYSQL_CLIENT}" != "true" && "${INSTALL_MYSQL_CLIENT}" != "false" ]]; then - echo - echo "${COLOR_RED}INSTALL_MYSQL_CLIENT must be either true or false${COLOR_RESET}" - echo - exit 1 -fi - -if [[ "${INSTALL_MYSQL_CLIENT_TYPE}" != "mysql" && "${INSTALL_MYSQL_CLIENT_TYPE}" != "mariadb" ]]; then - echo - echo "${COLOR_RED}INSTALL_MYSQL_CLIENT_TYPE must be either mysql or mariadb${COLOR_RESET}" - echo - exit 1 -fi - -if [[ "${INSTALL_MYSQL_CLIENT_TYPE}" == "mysql" ]]; then - echo - echo "${COLOR_RED}The 'mysql' client type is not supported any more. Use 'mariadb' instead.${COLOR_RESET}" - echo - echo "The MySQL drivers are wrongly packaged and released by Oracle with an expiration date on their GPG keys," - echo "which causes builds to fail after the expiration date. MariaDB client is protocol-compatible with MySQL client." - echo "" - echo "Every two years the MySQL packages fail and Oracle team is always surprised and struggling" - echo "with fixes and re-signing the packages which lasts few days" - echo "See https://bugs.mysql.com/bug.php?id=113432 for more details." - echo "As a community we are not able to support this broken packaging practice from Oracle" - echo "Feel free however to install MySQL drivers on your own as extension of the image." - echo - exit 1 -fi - -retry() { - local retries=3 - local count=0 - # adding delay of 10 seconds - local delay=10 - until "$@"; do - exit_code=$? - count=$((count + 1)) - if [[ $count -lt $retries ]]; then - echo "Command failed. Attempt $count/$retries. Retrying in ${delay}s..." - sleep $delay - else - echo "Command failed after $retries attempts." - return $exit_code - fi - done -} - -install_mariadb_client() { - # List of compatible package Oracle MySQL -> MariaDB: - # `mysql-client` -> `mariadb-client` or `mariadb-client-compat` (11+) - # `libmysqlclientXX` (where XX is a number) -> `libmariadb3-compat` - # `libmysqlclient-dev` -> `libmariadb-dev-compat` - # - # Different naming against Debian repo which we used before - # that some of packages might contains `-compat` suffix, Debian repo -> MariaDB repo: - # `libmariadb-dev` -> `libmariadb-dev-compat` - # `mariadb-client-core` -> `mariadb-client` or `mariadb-client-compat` (11+) - if [[ "${1}" == "dev" ]]; then - packages=("libmariadb-dev-compat" "mariadb-client") - elif [[ "${1}" == "prod" ]]; then - packages=("libmariadb3-compat" "mariadb-client") - else - echo - echo "${COLOR_RED}Specify either prod or dev${COLOR_RESET}" - echo - exit 1 - fi - - common::import_trusted_gpg "0xF1656F24C74CD1D8" "mariadb" - - echo - echo "${COLOR_BLUE}Installing MariaDB client version ${MARIADB_LTS_VERSION}: ${1}${COLOR_RESET}" - echo "${COLOR_YELLOW}MariaDB client protocol-compatible with MySQL client.${COLOR_RESET}" - echo - - echo "deb [arch=amd64,arm64] https://archive.mariadb.org/mariadb-${MARIADB_LTS_VERSION}/repo/debian/ $(lsb_release -cs) main" > \ - /etc/apt/sources.list.d/mariadb.list - # Make sure that dependencies from MariaDB repo are preferred over Debian dependencies - printf "Package: *\nPin: release o=MariaDB\nPin-Priority: 999\n" > /etc/apt/preferences.d/mariadb - retry apt-get update - retry apt-get install --no-install-recommends -y "${packages[@]}" - apt-get autoremove -yqq --purge - apt-get clean && rm -rf /var/lib/apt/lists/* -} - -if [[ ${INSTALL_MYSQL_CLIENT:="true"} == "true" ]]; then - install_mariadb_client "${@}" -fi -EOF - -# The content below is automatically copied from scripts/docker/install_mssql.sh -COPY <<"EOF" /install_mssql.sh -#!/usr/bin/env bash -. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" - -set -euo pipefail - -common::get_colors -declare -a packages - -: "${INSTALL_MSSQL_CLIENT:?Should be true or false}" - - -function install_mssql_client() { - # Install MsSQL client from Microsoft repositories - if [[ ${INSTALL_MSSQL_CLIENT:="true"} != "true" ]]; then - echo - echo "${COLOR_BLUE}Skip installing mssql client${COLOR_RESET}" - echo - return - fi - return #TODO here - packages=("msodbcsql18") - - common::import_trusted_gpg "EB3E94ADBE1229CF" "microsoft" - - echo - echo "${COLOR_BLUE}Installing mssql client${COLOR_RESET}" - echo - - echo "deb [arch=amd64,arm64] https://packages.microsoft.com/debian/$(lsb_release -rs)/prod $(lsb_release -cs) main" > \ - /etc/apt/sources.list.d/mssql-release.list && - mkdir -p /opt/microsoft/msodbcsql18 && - touch /opt/microsoft/msodbcsql18/ACCEPT_EULA && - apt-get update -yqq && - apt-get upgrade -yqq && - apt-get -yqq install --no-install-recommends "${packages[@]}" && - apt-get autoremove -yqq --purge && - apt-get clean && - rm -rf /var/lib/apt/lists/* -} - -install_mssql_client "${@}" -EOF - -# The content below is automatically copied from scripts/docker/install_postgres.sh -COPY <<"EOF" /install_postgres.sh -#!/usr/bin/env bash -. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" -set -euo pipefail - -common::get_colors -declare -a packages - -: "${INSTALL_POSTGRES_CLIENT:?Should be true or false}" - -install_postgres_client() { - echo - echo "${COLOR_BLUE}Installing postgres client${COLOR_RESET}" - echo - - if [[ "${1}" == "dev" ]]; then - packages=("libpq-dev" "postgresql-client") - elif [[ "${1}" == "prod" ]]; then - packages=("postgresql-client") - else - echo - echo "Specify either prod or dev" - echo - exit 1 - fi - - common::import_trusted_gpg "7FCC7D46ACCC4CF8" "postgres" - - echo "deb [arch=amd64,arm64] https://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > \ - /etc/apt/sources.list.d/pgdg.list - apt-get update - apt-get install --no-install-recommends -y "${packages[@]}" - apt-get autoremove -yqq --purge - apt-get clean && rm -rf /var/lib/apt/lists/* -} - -if [[ ${INSTALL_POSTGRES_CLIENT:="true"} == "true" ]]; then - install_postgres_client "${@}" -fi -EOF - -# The content below is automatically copied from scripts/docker/install_packaging_tools.sh -COPY <<"EOF" /install_packaging_tools.sh -#!/usr/bin/env bash -. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" - -common::get_colors -common::get_packaging_tool -common::show_packaging_tool_version_and_location -common::install_packaging_tools -EOF - -# The content below is automatically copied from scripts/docker/common.sh -COPY <<"EOF" /common.sh -#!/usr/bin/env bash -set -euo pipefail - -function common::get_colors() { - COLOR_BLUE=$'\e[34m' - COLOR_GREEN=$'\e[32m' - COLOR_RED=$'\e[31m' - COLOR_RESET=$'\e[0m' - COLOR_YELLOW=$'\e[33m' - export COLOR_BLUE - export COLOR_GREEN - export COLOR_RED - export COLOR_RESET - export COLOR_YELLOW -} - -function common::get_packaging_tool() { - : "${AIRFLOW_USE_UV:?Should be set}" - - ## IMPORTANT: IF YOU MODIFY THIS FUNCTION YOU SHOULD ALSO MODIFY CORRESPONDING FUNCTION IN - ## `scripts/in_container/_in_container_utils.sh` - if [[ ${AIRFLOW_USE_UV} == "true" ]]; then - echo - echo "${COLOR_BLUE}Using 'uv' to install Airflow${COLOR_RESET}" - echo - export PACKAGING_TOOL="uv" - export PACKAGING_TOOL_CMD="uv pip" - # --no-binary is needed in order to avoid libxml and xmlsec using different version of libxml2 - # (binary lxml embeds its own libxml2, while xmlsec uses system one). - # See https://bugs.launchpad.net/lxml/+bug/2110068 - if [[ ${AIRFLOW_INSTALLATION_METHOD=} == "." && -f "./pyproject.toml" ]]; then - # for uv only install dev group when we install from sources - export EXTRA_INSTALL_FLAGS="--group=dev --no-binary lxml --no-binary xmlsec" - else - export EXTRA_INSTALL_FLAGS="--no-binary lxml --no-binary xmlsec" - fi - export EXTRA_UNINSTALL_FLAGS="" - export UPGRADE_TO_HIGHEST_RESOLUTION="--upgrade --resolution highest" - export UPGRADE_IF_NEEDED="--upgrade" - UV_CONCURRENT_DOWNLOADS=$(nproc --all) - export UV_CONCURRENT_DOWNLOADS - if [[ ${INCLUDE_PRE_RELEASE=} == "true" ]]; then - EXTRA_INSTALL_FLAGS="${EXTRA_INSTALL_FLAGS} --prerelease if-necessary" - fi - else - echo - echo "${COLOR_BLUE}Using 'pip' to install Airflow${COLOR_RESET}" - echo - export PACKAGING_TOOL="pip" - export PACKAGING_TOOL_CMD="pip" - # --no-binary is needed in order to avoid libxml and xmlsec using different version of libxml2 - # (binary lxml embeds its own libxml2, while xmlsec uses system one). - # See https://bugs.launchpad.net/lxml/+bug/2110068 - export EXTRA_INSTALL_FLAGS="--root-user-action ignore --no-binary lxml,xmlsec" - export EXTRA_UNINSTALL_FLAGS="--yes" - export UPGRADE_TO_HIGHEST_RESOLUTION="--upgrade --upgrade-strategy eager" - export UPGRADE_IF_NEEDED="--upgrade --upgrade-strategy only-if-needed" - if [[ ${INCLUDE_PRE_RELEASE=} == "true" ]]; then - EXTRA_INSTALL_FLAGS="${EXTRA_INSTALL_FLAGS} --pre" - fi - fi -} - -function common::get_airflow_version_specification() { - if [[ -z ${AIRFLOW_VERSION_SPECIFICATION=} - && -n ${AIRFLOW_VERSION} - && ${AIRFLOW_INSTALLATION_METHOD} != "." ]]; then - AIRFLOW_VERSION_SPECIFICATION="==${AIRFLOW_VERSION}" - fi -} - -function common::get_constraints_location() { - # When installing from sources without upgrade, generate constraints from uv.lock - if [[ ${AIRFLOW_INSTALLATION_METHOD=} == "." && -z "${UPGRADE_RANDOM_INDICATOR_STRING=}" ]]; then - echo - echo "${COLOR_BLUE}Installing from sources with uv.lock - generating constraints from uv.lock${COLOR_RESET}" - echo - uv export --frozen --no-hashes --no-emit-project --no-editable --no-header \ - --no-annotate > "${HOME}/constraints.txt" 2>/dev/null || true - return - fi - - # auto-detect Airflow-constraint reference and location - if [[ -z "${AIRFLOW_CONSTRAINTS_REFERENCE=}" ]]; then - if [[ ${AIRFLOW_VERSION} =~ v?2.* || ${AIRFLOW_VERSION} =~ v?3.* ]]; then - AIRFLOW_CONSTRAINTS_REFERENCE=constraints-${AIRFLOW_VERSION} - else - AIRFLOW_CONSTRAINTS_REFERENCE=${DEFAULT_CONSTRAINTS_BRANCH} - fi - fi - - if [[ -z ${AIRFLOW_CONSTRAINTS_LOCATION=} ]]; then - local constraints_base="https://raw.githubusercontent.com/${CONSTRAINTS_GITHUB_REPOSITORY}/${AIRFLOW_CONSTRAINTS_REFERENCE}" - local python_version - python_version=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') - AIRFLOW_CONSTRAINTS_LOCATION="${constraints_base}/${AIRFLOW_CONSTRAINTS_MODE}-${python_version}.txt" - fi - - if [[ ${AIRFLOW_CONSTRAINTS_LOCATION} =~ http.* ]]; then - echo - echo "${COLOR_BLUE}Downloading constraints from ${AIRFLOW_CONSTRAINTS_LOCATION} to ${HOME}/constraints.txt ${COLOR_RESET}" - echo - if ! curl -sSf -o "${HOME}/constraints.txt" "${AIRFLOW_CONSTRAINTS_LOCATION}"; then - echo - echo "${COLOR_YELLOW}Constraints file not found at ${AIRFLOW_CONSTRAINTS_LOCATION} (new Python version being bootstrapped?).${COLOR_RESET}" - echo "${COLOR_YELLOW}Falling back to no-constraints installation.${COLOR_RESET}" - echo - AIRFLOW_CONSTRAINTS_LOCATION="" - # Create an empty constraints file so --constraint flag still works - touch "${HOME}/constraints.txt" - fi - else - echo - echo "${COLOR_BLUE}Copying constraints from ${AIRFLOW_CONSTRAINTS_LOCATION} to ${HOME}/constraints.txt ${COLOR_RESET}" - echo - cp "${AIRFLOW_CONSTRAINTS_LOCATION}" "${HOME}/constraints.txt" - fi -} - -function common::show_packaging_tool_version_and_location() { - echo "PATH=${PATH}" - echo "Installed pip: $(pip --version): $(which pip)" - if [[ ${PACKAGING_TOOL} == "pip" ]]; then - echo "${COLOR_BLUE}Using 'pip' to install Airflow${COLOR_RESET}" - else - echo "${COLOR_BLUE}Using 'uv' to install Airflow${COLOR_RESET}" - echo "Installed uv: $(uv --version 2>/dev/null || echo "Not installed yet"): $(which uv 2>/dev/null)" - fi -} - -function common::install_packaging_tools() { - : "${AIRFLOW_USE_UV:?Should be set}" - if [[ "${VIRTUAL_ENV=}" != "" ]]; then - echo - echo "${COLOR_BLUE}Checking packaging tools in venv: ${VIRTUAL_ENV}${COLOR_RESET}" - echo - else - echo - echo "${COLOR_BLUE}Checking packaging tools for system Python installation: $(which python)${COLOR_RESET}" - echo - fi - if [[ ${AIRFLOW_PIP_VERSION=} == "" ]]; then - echo - echo "${COLOR_BLUE}Installing latest pip version${COLOR_RESET}" - echo - pip install --root-user-action ignore --disable-pip-version-check --upgrade pip - elif [[ ! ${AIRFLOW_PIP_VERSION} =~ ^[0-9].* ]]; then - echo - echo "${COLOR_BLUE}Installing pip version from spec ${AIRFLOW_PIP_VERSION}${COLOR_RESET}" - echo - # shellcheck disable=SC2086 - pip install --root-user-action ignore --disable-pip-version-check "pip @ ${AIRFLOW_PIP_VERSION}" - else - local installed_pip_version - installed_pip_version=$(python -c 'from importlib.metadata import version; print(version("pip"))') - if [[ ${installed_pip_version} != "${AIRFLOW_PIP_VERSION}" ]]; then - echo - echo "${COLOR_BLUE}(Re)Installing pip version: ${AIRFLOW_PIP_VERSION}${COLOR_RESET}" - echo - pip install --root-user-action ignore --disable-pip-version-check "pip==${AIRFLOW_PIP_VERSION}" - fi - fi - if [[ ${AIRFLOW_UV_VERSION=} == "" ]]; then - echo - echo "${COLOR_BLUE}Installing latest uv version${COLOR_RESET}" - echo - pip install --root-user-action ignore --disable-pip-version-check --upgrade uv - elif [[ ! ${AIRFLOW_UV_VERSION} =~ ^[0-9].* ]]; then - echo - echo "${COLOR_BLUE}Installing uv version from spec ${AIRFLOW_UV_VERSION}${COLOR_RESET}" - echo - # shellcheck disable=SC2086 - pip install --root-user-action ignore --disable-pip-version-check "uv @ ${AIRFLOW_UV_VERSION}" - else - local installed_uv_version - installed_uv_version=$(python -c 'from importlib.metadata import version; print(version("uv"))' 2>/dev/null || echo "Not installed yet") - if [[ ${installed_uv_version} != "${AIRFLOW_UV_VERSION}" ]]; then - echo - echo "${COLOR_BLUE}(Re)Installing uv version: ${AIRFLOW_UV_VERSION}${COLOR_RESET}" - echo - # shellcheck disable=SC2086 - pip install --root-user-action ignore --disable-pip-version-check "uv==${AIRFLOW_UV_VERSION}" - fi - fi - if [[ ${AIRFLOW_PREK_VERSION=} == "" ]]; then - echo - echo "${COLOR_BLUE}Installing latest prek, uv${COLOR_RESET}" - echo - uv tool install prek --with uv - # make sure that the venv/user in .local exists - mkdir -p "${HOME}/.local/bin" - else - echo - echo "${COLOR_BLUE}Installing predefined versions of prek, uv:${COLOR_RESET}" - echo "${COLOR_BLUE}prek(${AIRFLOW_PREK_VERSION}) uv(${AIRFLOW_UV_VERSION})${COLOR_RESET}" - echo - uv tool install "prek==${AIRFLOW_PREK_VERSION}" --with "uv==${AIRFLOW_UV_VERSION}" - # make sure that the venv/user in .local exists - mkdir -p "${HOME}/.local/bin" - fi -} - -function common::import_trusted_gpg() { - common::get_colors - - local key=${1:?${COLOR_RED}First argument expects OpenPGP Key ID${COLOR_RESET}} - local name=${2:?${COLOR_RED}Second argument expected trust storage name${COLOR_RESET}} - local key_file="/scripts/docker/keys/${name}.asc" - - echo "${COLOR_BLUE}Installing GPG public key ${key} from ${key_file}${COLOR_RESET}" - gpg --dearmor < "${key_file}" > "/etc/apt/trusted.gpg.d/${name}.gpg" -} -EOF - -# The content below is automatically copied from scripts/docker/pip -COPY <<"EOF" /pip -#!/usr/bin/env bash -COLOR_RED=$'\e[31m' -COLOR_RESET=$'\e[0m' -COLOR_YELLOW=$'\e[33m' - -if [[ $(id -u) == "0" ]]; then - echo - echo "${COLOR_RED}You are running pip as root. Please use 'airflow' user to run pip!${COLOR_RESET}" - echo - echo "${COLOR_YELLOW}See: https://airflow.apache.org/docs/docker-stack/build.html#adding-new-pypi-packages-individually${COLOR_RESET}" - echo - exit 1 -fi -exec "${HOME}"/.local/bin/pip "${@}" -EOF - -# The content below is automatically copied from scripts/docker/install_from_docker_context_files.sh -COPY <<"EOF" /install_from_docker_context_files.sh - -. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" - - -function install_airflow_and_providers_from_docker_context_files(){ - local flags=() - if [[ ${INSTALL_MYSQL_CLIENT} != "true" ]]; then - AIRFLOW_EXTRAS=${AIRFLOW_EXTRAS/mysql,} - fi - if [[ ${INSTALL_POSTGRES_CLIENT} != "true" ]]; then - AIRFLOW_EXTRAS=${AIRFLOW_EXTRAS/postgres,} - fi - - if [[ ! -d /docker-context-files ]]; then - echo - echo "${COLOR_RED}You must provide a folder via --build-arg DOCKER_CONTEXT_FILES= and you missed it!${COLOR_RESET}" - echo - exit 1 - fi - - # This is needed to get distribution names for local context distributions - if [[ -f "${HOME}/constraints.txt" ]]; then - ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${ADDITIONAL_PIP_INSTALL_FLAGS} --constraint ${HOME}/constraints.txt packaging - else - ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${ADDITIONAL_PIP_INSTALL_FLAGS} packaging - fi - - if [[ -n ${AIRFLOW_EXTRAS=} ]]; then - AIRFLOW_EXTRAS_TO_INSTALL="[${AIRFLOW_EXTRAS}]" - else - AIRFLOW_EXTRAS_TO_INSTALL="" - fi - - # Find apache-airflow distribution in docker-context files - readarray -t install_airflow_distribution < <(EXTRAS="${AIRFLOW_EXTRAS_TO_INSTALL}" \ - python /scripts/docker/get_distribution_specs.py /docker-context-files/apache?airflow?[0-9]*.{whl,tar.gz} 2>/dev/null || true) - echo - echo "${COLOR_BLUE}Found apache-airflow distributions in docker-context-files folder: ${install_airflow_distribution[*]}${COLOR_RESET}" - echo - - if [[ -z "${install_airflow_distribution[*]}" && ${AIRFLOW_VERSION=} != "" ]]; then - # When we install only provider distributions from docker-context files, we need to still - # install airflow from PyPI when AIRFLOW_VERSION is set. This handles the case where - # pre-release dockerhub image of airflow is built, but we want to install some providers from - # docker-context files - install_airflow_distribution=("apache-airflow[${AIRFLOW_EXTRAS}]==${AIRFLOW_VERSION}") - fi - - # Find apache-airflow-core distribution in docker-context files - readarray -t install_airflow_core_distribution < <(EXTRAS="" \ - python /scripts/docker/get_distribution_specs.py /docker-context-files/apache?airflow?core?[0-9]*.{whl,tar.gz} 2>/dev/null || true) - echo - echo "${COLOR_BLUE}Found apache-airflow-core distributions in docker-context-files folder: ${install_airflow_core_distribution[*]}${COLOR_RESET}" - echo - - if [[ -z "${install_airflow_core_distribution[*]}" && ${AIRFLOW_VERSION=} != "" ]]; then - # When we install only provider distributions from docker-context files, we need to still - # install airflow from PyPI when AIRFLOW_VERSION is set. This handles the case where - # pre-release dockerhub image of airflow is built, but we want to install some providers from - # docker-context files - install_airflow_core_distribution=("apache-airflow-core==${AIRFLOW_VERSION}") - fi - - # Find Provider/TaskSDK/CTL distributions in docker-context files. - # NOTE: the ctl wheel is named ``apache_airflow_ctl-*.whl`` (distribution - # ``apache-airflow-ctl``), not ``apache_airflow_airflowctl-*.whl`` — the - # glob must say ``ctl``, not ``airflowctl``. - readarray -t airflow_distributions< <(python /scripts/docker/get_distribution_specs.py /docker-context-files/apache?airflow?{providers,task?sdk,ctl}*.{whl,tar.gz} 2>/dev/null || true) - echo - echo "${COLOR_BLUE}Found provider distributions in docker-context-files folder: ${airflow_distributions[*]}${COLOR_RESET}" - echo - - if [[ ${USE_CONSTRAINTS_FOR_CONTEXT_DISTRIBUTIONS=} == "true" ]]; then - local python_version - python_version=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') - local local_constraints_file=/docker-context-files/constraints-"${python_version}"/${AIRFLOW_CONSTRAINTS_MODE}-"${python_version}".txt - - if [[ -f "${local_constraints_file}" ]]; then - echo - echo "${COLOR_BLUE}Installing docker-context-files distributions with constraints found in ${local_constraints_file}${COLOR_RESET}" - echo - # force reinstall all airflow + provider distributions with constraints found in - flags=(--upgrade --constraint "${local_constraints_file}") - echo - echo "${COLOR_BLUE}Copying ${local_constraints_file} to ${HOME}/constraints.txt${COLOR_RESET}" - echo - cp "${local_constraints_file}" "${HOME}/constraints.txt" - else - echo - echo "${COLOR_BLUE}Installing docker-context-files distributions with constraints from GitHub${COLOR_RESET}" - echo - flags=(--constraint "${HOME}/constraints.txt") - fi - else - echo - echo "${COLOR_BLUE}Installing docker-context-files distributions without constraints${COLOR_RESET}" - echo - flags=() - fi - - set -x - if ! ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} \ - ${ADDITIONAL_PIP_INSTALL_FLAGS} \ - "${flags[@]}" \ - "${install_airflow_distribution[@]}" "${install_airflow_core_distribution[@]}" "${airflow_distributions[@]}"; then - set +x - if [[ ${AIRFLOW_FALLBACK_NO_CONSTRAINTS_INSTALLATION} != "true" ]]; then - echo - echo "${COLOR_RED}Failing because constraints installation failed and fallback is disabled.${COLOR_RESET}" - echo - exit 1 - fi - echo - echo "${COLOR_YELLOW}Likely there are new dependencies conflicting with constraints.${COLOR_RESET}" - echo - echo "${COLOR_BLUE}Falling back to no-constraints installation.${COLOR_RESET}" - echo - set -x - ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} \ - ${ADDITIONAL_PIP_INSTALL_FLAGS} \ - "${install_airflow_distribution[@]}" "${install_airflow_core_distribution[@]}" \ - "${airflow_distributions[@]}" - fi - set +x - common::install_packaging_tools - # We use pip check here to make sure that whatever `uv` installs, is also "correct" according to `pip` - pip check -} - -function install_all_other_distributions_from_docker_context_files() { - echo - echo "${COLOR_BLUE}Force re-installing all other distributions from local files without dependencies${COLOR_RESET}" - echo - local reinstalling_other_distributions - # shellcheck disable=SC2010 - reinstalling_other_distributions=$(ls /docker-context-files/*.{whl,tar.gz} 2>/dev/null | \ - grep -v apache_airflow | grep -v apache-airflow || true) - if [[ -n "${reinstalling_other_distributions}" ]]; then - set -x - ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${ADDITIONAL_PIP_INSTALL_FLAGS} \ - --force-reinstall --no-deps --no-index ${reinstalling_other_distributions} - common::install_packaging_tools - set +x - fi -} - -common::get_colors -common::get_packaging_tool -common::get_airflow_version_specification -common::get_constraints_location -common::show_packaging_tool_version_and_location - -install_airflow_and_providers_from_docker_context_files - -install_all_other_distributions_from_docker_context_files -EOF - -# The content below is automatically copied from scripts/docker/get_distribution_specs.py -COPY <<"EOF" /get_distribution_specs.py -#!/usr/bin/env python -from __future__ import annotations - -import os -import sys -import zipfile -from email.parser import HeaderParser -from pathlib import Path - -from packaging.specifiers import InvalidSpecifier, SpecifierSet -from packaging.utils import ( - InvalidSdistFilename, - InvalidWheelFilename, - parse_sdist_filename, - parse_wheel_filename, -) - -_CURRENT_PYTHON = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" - - -def _compatible_with_current_python(wheel_path: str) -> bool: - """Return False if the wheel's Requires-Python excludes the running interpreter.""" - try: - with zipfile.ZipFile(wheel_path) as zf: - for name in zf.namelist(): - if name.endswith(".dist-info/METADATA"): - requires = HeaderParser().parsestr(zf.read(name).decode("utf-8")).get("Requires-Python") - if requires: - return _CURRENT_PYTHON in SpecifierSet(requires) - return True - except (zipfile.BadZipFile, InvalidSpecifier, KeyError) as exc: - print(f"Warning: could not check Requires-Python for {wheel_path}: {exc}", file=sys.stderr) - return True - - -def print_package_specs(extras: str = "") -> None: - for package_path in sys.argv[1:]: - try: - package, _, _, _ = parse_wheel_filename(Path(package_path).name) - except InvalidWheelFilename: - try: - package, _ = parse_sdist_filename(Path(package_path).name) - except InvalidSdistFilename: - print(f"Could not parse package name from {package_path}", file=sys.stderr) - continue - if package_path.endswith(".whl") and not _compatible_with_current_python(package_path): - print( - f"Skipping {package} (Requires-Python not satisfied by {_CURRENT_PYTHON})", - file=sys.stderr, - ) - continue - print(f"{package}{extras} @ file://{package_path}") - - -if __name__ == "__main__": - print_package_specs(extras=os.environ.get("EXTRAS", "")) -EOF - - -# The content below is automatically copied from scripts/docker/install_airflow_when_building_images.sh -COPY <<"EOF" /install_airflow_when_building_images.sh -#!/usr/bin/env bash - -. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" - -function install_from_sources() { - local extra_sync_flags - extra_sync_flags="" - if [[ ${VIRTUAL_ENV=} != "" ]]; then - extra_sync_flags="--active" - fi - if [[ "${UPGRADE_RANDOM_INDICATOR_STRING=}" != "" ]]; then - if [[ ${PACKAGING_TOOL_CMD} == "pip" ]]; then - set +x - echo - echo "${COLOR_RED}We only support uv not pip installation for upgrading dependencies!.${COLOR_RESET}" - echo - exit 1 - fi - set +x - echo - echo "${COLOR_BLUE}Attempting to upgrade all packages to highest versions.${COLOR_RESET}" - echo - # --no-binary is needed in order to avoid libxml and xmlsec using different version of libxml2 - # (binary lxml embeds its own libxml2, while xmlsec uses system one). - # See https://bugs.launchpad.net/lxml/+bug/2110068 - set -x - uv sync --all-packages --resolution highest --group ci-image \ - ${extra_sync_flags} --no-binary-package lxml --no-binary-package xmlsec \ - --no-python-downloads --no-managed-python - else - set +x - echo - echo "${COLOR_BLUE}Installing all packages from uv.lock (frozen).${COLOR_RESET}" - echo - # Use uv sync --frozen to install exactly what is pinned in uv.lock without re-resolving. - # --no-binary-package is needed in order to avoid libxml and xmlsec using different version of - # libxml2 (binary lxml embeds its own libxml2, while xmlsec uses system one). - # See https://bugs.launchpad.net/lxml/+bug/2110068 - set -x - if ! uv sync --all-packages --frozen --group ci-image \ - ${extra_sync_flags} --no-binary-package lxml --no-binary-package xmlsec \ - --no-python-downloads --no-managed-python; then - set +x - if [[ ${AIRFLOW_FALLBACK_NO_CONSTRAINTS_INSTALLATION} != "true" ]]; then - echo - echo "${COLOR_RED}Failing because frozen uv.lock installation failed and fallback is disabled.${COLOR_RESET}" - echo - exit 1 - fi - echo - echo "${COLOR_YELLOW}Likely pyproject.toml has new dependencies not reflected in uv.lock.${COLOR_RESET}" - echo - echo "${COLOR_BLUE}Falling back to re-resolving dependencies (uv sync without --frozen).${COLOR_RESET}" - echo - set -x - uv sync --all-packages --group ci-image \ - ${extra_sync_flags} --no-binary-package lxml --no-binary-package xmlsec \ - --no-python-downloads --no-managed-python - set +x - fi - fi -} - -function install_from_external_spec() { - local installation_command_flags - if [[ ${AIRFLOW_INSTALLATION_METHOD} == "apache-airflow" ]]; then - installation_command_flags="apache-airflow[${AIRFLOW_EXTRAS}]${AIRFLOW_VERSION_SPECIFICATION}" - else - echo - echo "${COLOR_RED}The '${AIRFLOW_INSTALLATION_METHOD}' installation method is not supported${COLOR_RESET}" - echo - echo "${COLOR_YELLOW}Supported methods are ('.', 'apache-airflow')${COLOR_RESET}" - echo - exit 1 - fi - if [[ "${UPGRADE_RANDOM_INDICATOR_STRING=}" != "" ]]; then - echo - echo "${COLOR_BLUE}Remove airflow and all provider distributions installed before potentially${COLOR_RESET}" - echo - set -x - ${PACKAGING_TOOL_CMD} freeze | grep apache-airflow | xargs ${PACKAGING_TOOL_CMD} uninstall ${EXTRA_UNINSTALL_FLAGS} 2>/dev/null || true - set +x - echo - echo "${COLOR_BLUE}Installing all packages with highest resolutions. Installation method: ${AIRFLOW_INSTALLATION_METHOD}${COLOR_RESET}" - echo - set -x - ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${UPGRADE_TO_HIGHEST_RESOLUTION} ${ADDITIONAL_PIP_INSTALL_FLAGS} ${installation_command_flags} - set +x - else - echo - echo "${COLOR_BLUE}Installing all packages with constraints. Installation method: ${AIRFLOW_INSTALLATION_METHOD}${COLOR_RESET}" - echo - set -x - if ! ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${ADDITIONAL_PIP_INSTALL_FLAGS} ${installation_command_flags} --constraint "${HOME}/constraints.txt"; then - set +x - if [[ ${AIRFLOW_FALLBACK_NO_CONSTRAINTS_INSTALLATION} != "true" ]]; then - echo - echo "${COLOR_RED}Failing because constraints installation failed and fallback is disabled.${COLOR_RESET}" - echo - exit 1 - fi - echo - echo "${COLOR_YELLOW}Likely pyproject.toml has new dependencies conflicting with constraints.${COLOR_RESET}" - echo - echo "${COLOR_BLUE}Falling back to no-constraints installation.${COLOR_RESET}" - echo - set -x - ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${UPGRADE_IF_NEEDED} ${ADDITIONAL_PIP_INSTALL_FLAGS} ${installation_command_flags} - set +x - fi - fi -} - - -function install_airflow_when_building_images() { - # Remove mysql from extras if client is not going to be installed - if [[ ${INSTALL_MYSQL_CLIENT} != "true" ]]; then - AIRFLOW_EXTRAS=${AIRFLOW_EXTRAS/mysql,} - echo "${COLOR_YELLOW}MYSQL client installation is disabled. Extra 'mysql' installations were therefore omitted.${COLOR_RESET}" - fi - # Remove postgres from extras if client is not going to be installed - if [[ ${INSTALL_POSTGRES_CLIENT} != "true" ]]; then - AIRFLOW_EXTRAS=${AIRFLOW_EXTRAS/postgres,} - echo "${COLOR_YELLOW}Postgres client installation is disabled. Extra 'postgres' installations were therefore omitted.${COLOR_RESET}" - fi - # Determine the installation_command_flags based on AIRFLOW_INSTALLATION_METHOD method - if [[ ${AIRFLOW_INSTALLATION_METHOD} == "." ]]; then - install_from_sources - else - install_from_external_spec - fi - set +x - common::install_packaging_tools - echo - echo "${COLOR_BLUE}Running 'pip check'${COLOR_RESET}" - echo - # We use pip check here to make sure that whatever `uv` installs, is also "correct" according to `pip` - pip check -} - -common::get_colors -common::get_packaging_tool -common::get_airflow_version_specification -common::get_constraints_location -common::show_packaging_tool_version_and_location - -install_airflow_when_building_images -EOF - -# The content below is automatically copied from scripts/docker/install_additional_dependencies.sh -COPY <<"EOF" /install_additional_dependencies.sh -#!/usr/bin/env bash -set -euo pipefail - -: "${ADDITIONAL_PYTHON_DEPS:?Should be set}" - -. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" - -function install_additional_dependencies() { - if [[ "${UPGRADE_RANDOM_INDICATOR_STRING=}" != "" ]]; then - echo - echo "${COLOR_BLUE}Installing additional dependencies while upgrading to newer dependencies${COLOR_RESET}" - echo - set -x - ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${UPGRADE_TO_HIGHEST_RESOLUTION} \ - ${ADDITIONAL_PIP_INSTALL_FLAGS} \ - ${ADDITIONAL_PYTHON_DEPS} - set +x - common::install_packaging_tools - echo - echo "${COLOR_BLUE}Running 'pip check'${COLOR_RESET}" - echo - # We use pip check here to make sure that whatever `uv` installs, is also "correct" according to `pip` - pip check - else - echo - echo "${COLOR_BLUE}Installing additional dependencies upgrading only if needed${COLOR_RESET}" - echo - set -x - ${PACKAGING_TOOL_CMD} install ${EXTRA_INSTALL_FLAGS} ${UPGRADE_IF_NEEDED} \ - ${ADDITIONAL_PIP_INSTALL_FLAGS} \ - ${ADDITIONAL_PYTHON_DEPS} - set +x - common::install_packaging_tools - echo - echo "${COLOR_BLUE}Running 'pip check'${COLOR_RESET}" - echo - # We use pip check here to make sure that whatever `uv` installs, is also "correct" according to `pip` - pip check - fi -} - -common::get_colors -common::get_packaging_tool -common::get_airflow_version_specification -common::get_constraints_location -common::show_packaging_tool_version_and_location - -install_additional_dependencies -EOF - -# The content below is automatically copied from scripts/docker/create_prod_venv.sh -COPY <<"EOF" /create_prod_venv.sh -#!/usr/bin/env bash -. "$( dirname "${BASH_SOURCE[0]}" )/common.sh" - -function create_prod_venv() { - echo - echo "${COLOR_BLUE}Removing ${HOME}/.local and re-creating it as virtual environment.${COLOR_RESET}" - rm -rf ~/.local - python -m venv ~/.local - echo "${COLOR_BLUE}The ${HOME}/.local virtualenv created.${COLOR_RESET}" -} - -common::get_colors -common::get_packaging_tool -common::show_packaging_tool_version_and_location -create_prod_venv -common::install_packaging_tools -EOF - - -# The content below is automatically copied from scripts/docker/entrypoint_prod.sh -COPY <<"EOF" /entrypoint_prod.sh -#!/usr/bin/env bash -AIRFLOW_COMMAND="${1:-}" -AIRFLOW_COMMAND_TO_RUN="${AIRFLOW_COMMAND}" -if [[ "${AIRFLOW_COMMAND}" == "airflow" ]]; then - AIRFLOW_COMMAND_TO_RUN="${2:-}" -elif [[ "${AIRFLOW_COMMAND}" =~ ^(bash|sh)$ ]] \ - && [[ "${2:-}" == "-c" ]] \ - && [[ "${3:-}" =~ (^|[[:space:]])(exec[[:space:]]+)?airflow[[:space:]]+(scheduler|dag-processor|triggerer|api-server)([[:space:]]|$) ]]; then - AIRFLOW_COMMAND_TO_RUN="${BASH_REMATCH[3]}" -fi - -set -euo pipefail - -LD_PRELOAD="/usr/lib/$(uname -m)-linux-gnu/libstdc++.so.6" -export LD_PRELOAD - -function run_check_with_retries { - local cmd - cmd="${1}" - local countdown - countdown="${CONNECTION_CHECK_MAX_COUNT}" - - while true - do - set +e - local last_check_result - local res - last_check_result=$(eval "${cmd} 2>&1") - res=$? - set -e - if [[ ${res} == 0 ]]; then - echo - break - else - echo -n "." - countdown=$((countdown-1)) - fi - if [[ ${countdown} == 0 ]]; then - echo - echo "ERROR! Maximum number of retries (${CONNECTION_CHECK_MAX_COUNT}) reached." - echo - echo "Last check result:" - echo "$ ${cmd}" - echo "${last_check_result}" - echo - exit 1 - else - sleep "${CONNECTION_CHECK_SLEEP_TIME}" - fi - done -} - -function run_nc() { - # Checks if it is possible to connect to the host using netcat. - # - # We want to avoid misleading messages and perform only forward lookup of the service IP address. - # Netcat when run without -n performs both forward and reverse lookup and fails if the reverse - # lookup name does not match the original name even if the host is reachable via IP. This happens - # randomly with docker-compose in GitHub Actions. - # Since we are not using reverse lookup elsewhere, we can perform forward lookup in python - # And use the IP in NC and add '-n' switch to disable any DNS use. - # Even if this message might be harmless, it might hide the real reason for the problem - # Which is the long time needed to start some services, seeing this message might be totally misleading - # when you try to analyse the problem, that's why it's best to avoid it, - local host="${1}" - local port="${2}" - local ip - ip=$(python -c "import socket; print(socket.gethostbyname('${host}'))") - nc -zvvn "${ip}" "${port}" -} - - -function wait_for_connection { - # Waits for Connection to the backend specified via URL passed as first parameter - # Detects backend type depending on the URL schema and assigns - # default port numbers if not specified in the URL. - # Then it loops until connection to the host/port specified can be established - # It tries `CONNECTION_CHECK_MAX_COUNT` times and sleeps `CONNECTION_CHECK_SLEEP_TIME` between checks - local connection_url - connection_url="${1}" - local detected_backend - detected_backend=$(python -c "from urllib.parse import urlsplit; import sys; print(urlsplit(sys.argv[1]).scheme)" "${connection_url}") - local detected_host - detected_host=$(python -c "from urllib.parse import urlsplit; import sys; print(urlsplit(sys.argv[1]).hostname or '')" "${connection_url}") - local detected_port - detected_port=$(python -c "from urllib.parse import urlsplit; import sys; print(urlsplit(sys.argv[1]).port or '')" "${connection_url}") - - echo BACKEND="${BACKEND:=${detected_backend}}" - readonly BACKEND - - if [[ -z "${detected_port=}" ]]; then - if [[ ${BACKEND} == "postgres"* ]]; then - detected_port=5432 - elif [[ ${BACKEND} == "mysql"* ]]; then - detected_port=3306 - elif [[ ${BACKEND} == "mssql"* ]]; then - detected_port=1433 - elif [[ ${BACKEND} == "redis"* ]]; then - detected_port=6379 - elif [[ ${BACKEND} == "amqp"* ]]; then - detected_port=5672 - fi - fi - - detected_host=${detected_host:="localhost"} - - # Allow the DB parameters to be overridden by environment variable - echo DB_HOST="${DB_HOST:=${detected_host}}" - readonly DB_HOST - - echo DB_PORT="${DB_PORT:=${detected_port}}" - readonly DB_PORT - if [[ -n "${DB_HOST=}" ]] && [[ -n "${DB_PORT=}" ]]; then - run_check_with_retries "run_nc ${DB_HOST@Q} ${DB_PORT@Q}" - else - >&2 echo "The connection details to the broker could not be determined. Connectivity checks were skipped." - fi -} - -function create_www_user() { - local local_password="" - # Warning: command environment variables (*_CMD) have priority over usual configuration variables - # for configuration parameters that require sensitive information. This is the case for the SQL database - # and the broker backend in this entrypoint script. - if [[ -n "${_AIRFLOW_WWW_USER_PASSWORD_CMD=}" ]]; then - local_password=$(eval "${_AIRFLOW_WWW_USER_PASSWORD_CMD}") - unset _AIRFLOW_WWW_USER_PASSWORD_CMD - elif [[ -n "${_AIRFLOW_WWW_USER_PASSWORD=}" ]]; then - local_password="${_AIRFLOW_WWW_USER_PASSWORD}" - unset _AIRFLOW_WWW_USER_PASSWORD - fi - if [[ -z ${local_password} ]]; then - echo - echo "ERROR! Airflow Admin password not set via _AIRFLOW_WWW_USER_PASSWORD or _AIRFLOW_WWW_USER_PASSWORD_CMD variables!" - echo - exit 1 - fi - - if airflow config get-value core auth_manager | grep -q "FabAuthManager"; then - airflow users create \ - --username "${_AIRFLOW_WWW_USER_USERNAME="admin"}" \ - --firstname "${_AIRFLOW_WWW_USER_FIRSTNAME="Airflow"}" \ - --lastname "${_AIRFLOW_WWW_USER_LASTNAME="Admin"}" \ - --email "${_AIRFLOW_WWW_USER_EMAIL="airflowadmin@example.com"}" \ - --role "${_AIRFLOW_WWW_USER_ROLE="Admin"}" \ - --password "${local_password}" || true - else - echo "Skipping user creation as auth manager different from Fab is used" - fi -} - -function create_system_user_if_missing() { - # This is needed in case of OpenShift-compatible container execution. In case of OpenShift random - # User id is used when starting the image, however group 0 is kept as the user group. Our production - # Image is OpenShift compatible, so all permissions on all folders are set so that 0 group can exercise - # the same privileges as the default "airflow" user, this code checks if the user is already - # present in /etc/passwd and will create the system user dynamically, including setting its - # HOME directory to the /home/airflow so that (for example) the ${HOME}/.local folder where airflow is - # Installed can be automatically added to PYTHONPATH - if ! whoami &> /dev/null; then - if [[ -w /etc/passwd ]]; then - echo "${USER_NAME:-default}:x:$(id -u):0:${USER_NAME:-default} user:${AIRFLOW_USER_HOME_DIR}:/sbin/nologin" \ - >> /etc/passwd - fi - export HOME="${AIRFLOW_USER_HOME_DIR}" - fi -} - -function set_pythonpath_for_root_user() { - # Airflow is installed as a local user application which means that if the container is running as root - # the application is not available. because Python then only load system-wide applications. - # Now also adds applications installed as local user "airflow". - if [[ $UID == "0" ]]; then - local python_major_minor - python_major_minor=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') - export PYTHONPATH="${AIRFLOW_USER_HOME_DIR}/.local/lib/python${python_major_minor}/site-packages:${PYTHONPATH:-}" - >&2 echo "The container is run as root user. For security, consider using a regular user account." - fi -} - -function wait_for_airflow_db() { - # Wait for the command to run successfully to validate the database connection. - run_check_with_retries "airflow db check" -} - -function migrate_db() { - # Runs airflow db migrate - airflow db migrate || true -} - -function wait_for_celery_broker() { - # Verifies connection to Celery Broker - local executor - executor="$(airflow config get-value core executor)" - if [[ "${executor}" == "CeleryExecutor" ]]; then - local connection_url - connection_url="$(airflow config get-value celery broker_url)" - wait_for_connection "${connection_url}" - fi -} - -function exec_to_bash_or_python_command_if_specified() { - # If one of the commands: 'bash', 'python' is used, either run appropriate - # command with exec - if [[ ${AIRFLOW_COMMAND} == "bash" ]]; then - shift - exec "/bin/bash" "${@}" - elif [[ ${AIRFLOW_COMMAND} == "python" ]]; then - shift - exec "python" "${@}" - fi -} - -function check_uid_gid() { - if [[ $(id -g) == "0" ]]; then - return - fi - if [[ $(id -u) == "50000" ]]; then - >&2 echo - >&2 echo "WARNING! You should run the image with GID (Group ID) set to 0" - >&2 echo " even if you use 'airflow' user (UID=50000)" - >&2 echo - >&2 echo " You started the image with UID=$(id -u) and GID=$(id -g)" - >&2 echo - >&2 echo " This is to make sure you can run the image with an arbitrary UID in the future." - >&2 echo - >&2 echo " See more about it in the Airflow's docker image documentation" - >&2 echo " https://airflow.apache.org/docs/docker-stack/entrypoint.html" - >&2 echo - # We still allow the image to run with `airflow` user. - return - else - >&2 echo - >&2 echo "ERROR! You should run the image with GID=0" - >&2 echo - >&2 echo " You started the image with UID=$(id -u) and GID=$(id -g)" - >&2 echo - >&2 echo "The image should always be run with GID (Group ID) set to 0 regardless of the UID used." - >&2 echo " This is to make sure you can run the image with an arbitrary UID." - >&2 echo - >&2 echo " See more about it in the Airflow's docker image documentation" - >&2 echo " https://airflow.apache.org/docs/docker-stack/entrypoint.html" - # This will not work so we fail hard - exit 1 - fi -} - -unset PIP_USER - -check_uid_gid - -umask 0002 - -CONNECTION_CHECK_MAX_COUNT=${CONNECTION_CHECK_MAX_COUNT:=20} -readonly CONNECTION_CHECK_MAX_COUNT - -CONNECTION_CHECK_SLEEP_TIME=${CONNECTION_CHECK_SLEEP_TIME:=3} -readonly CONNECTION_CHECK_SLEEP_TIME - -create_system_user_if_missing -set_pythonpath_for_root_user -if [[ "${CONNECTION_CHECK_MAX_COUNT}" -gt "0" ]] \ - && [[ ${AIRFLOW_COMMAND_TO_RUN} =~ ^(scheduler|dag-processor|triggerer|api-server)$ ]]; then - wait_for_airflow_db -fi - -if [[ -n "${_AIRFLOW_DB_UPGRADE=}" ]] || [[ -n "${_AIRFLOW_DB_MIGRATE=}" ]] ; then - migrate_db -fi - -if [[ -n "${_AIRFLOW_DB_UPGRADE=}" ]] ; then - >&2 echo "WARNING: Environment variable '_AIRFLOW_DB_UPGRADE' is deprecated please use '_AIRFLOW_DB_MIGRATE' instead" -fi - -if [[ -n "${_AIRFLOW_WWW_USER_CREATE=}" ]] ; then - create_www_user -fi - -if [[ -n "${_PIP_ADDITIONAL_REQUIREMENTS=}" ]] ; then - >&2 echo - >&2 echo "!!!!! Installing additional requirements: '${_PIP_ADDITIONAL_REQUIREMENTS}' !!!!!!!!!!!!" - >&2 echo - >&2 echo "WARNING: This is a development/test feature only. NEVER use it in production!" - >&2 echo " Instead, build a custom image as described in" - >&2 echo - >&2 echo " https://airflow.apache.org/docs/docker-stack/build.html" - >&2 echo - >&2 echo " Adding requirements at container startup is fragile and is done every time" - >&2 echo " the container starts, so it is only useful for testing and trying out" - >&2 echo " of adding dependencies." - >&2 echo - pip install --root-user-action ignore ${_PIP_ADDITIONAL_REQUIREMENTS} -fi - - -exec_to_bash_or_python_command_if_specified "${@}" - -if [[ ${AIRFLOW_COMMAND} == "airflow" ]]; then - AIRFLOW_COMMAND="${2:-}" - shift -fi - -if [[ ${AIRFLOW_COMMAND} =~ ^(scheduler|celery)$ ]] \ - && [[ "${CONNECTION_CHECK_MAX_COUNT}" -gt "0" ]]; then - wait_for_celery_broker -fi - -if [[ "$#" -eq 0 && "${_AIRFLOW_DB_MIGRATE}" == "true" ]]; then - echo "[INFO] No commands passed and _AIRFLOW_DB_MIGRATE=true. Exiting script with code 0." - exit 0 -fi - -exec "airflow" "${@}" -EOF - -# The content below is automatically copied from scripts/docker/clean-logs.sh -COPY <<"EOF" /clean-logs.sh -#!/usr/bin/env bash - - -set -euo pipefail - -readonly DIRECTORY="${AIRFLOW_HOME:-/usr/local/airflow}" -readonly RETENTION_DAYS="${AIRFLOW__LOG_RETENTION_DAYS:-15}" -readonly RETENTION_MINUTES="${AIRFLOW__LOG_RETENTION_MINUTES:-0}" -readonly FREQUENCY="${AIRFLOW__LOG_CLEANUP_FREQUENCY_MINUTES:-15}" -readonly MAX_PERCENT="${AIRFLOW__LOG_MAX_SIZE_PERCENT:-0}" - -trap "exit" INT TERM - -MAX_SIZE_BYTES="${AIRFLOW__LOG_MAX_SIZE_BYTES:-0}" -if [[ "$MAX_SIZE_BYTES" -eq 0 && "$MAX_PERCENT" -gt 0 ]]; then - total_space=$(df -k "${DIRECTORY}"/logs 2>/dev/null | tail -1 | awk '{print $2}' || echo "0") - MAX_SIZE_BYTES=$(( total_space * 1024 * MAX_PERCENT / 100 )) - echo "Computed MAX_SIZE_BYTES from ${MAX_PERCENT}% of disk: ${MAX_SIZE_BYTES} bytes" -fi - -readonly MAX_SIZE_BYTES - -readonly EVERY=$((FREQUENCY*60)) - -echo "Cleaning logs every $EVERY seconds" -if [[ "$MAX_SIZE_BYTES" -gt 0 ]]; then - echo "Max log size limit: $MAX_SIZE_BYTES bytes" -fi - -retention_days="${RETENTION_DAYS}" - -while true; do - total_retention_minutes=$(( (retention_days * 1440) + RETENTION_MINUTES )) - echo "Trimming airflow logs older than ${total_retention_minutes} minutes." - - find "${DIRECTORY}"/logs \ - -type d -name 'lost+found' -prune -o \ - -type f -mmin +"${total_retention_minutes}" -name '*.log' -print0 | \ - xargs -0 rm -f || true - - if [[ "$MAX_SIZE_BYTES" -gt 0 && "$retention_days" -ge 0 ]]; then - current_size=$(df -k "${DIRECTORY}"/logs 2>/dev/null | tail -1 | awk '{print $3}' || echo "0") - current_size=$(( current_size * 1024 )) - - if [[ "$current_size" -gt "$MAX_SIZE_BYTES" ]]; then - retention_days=$((retention_days - 1)) - echo "Size ($current_size bytes) exceeds limit ($MAX_SIZE_BYTES bytes). Reducing retention to ${retention_days} days." - continue - fi - fi - - find "${DIRECTORY}"/logs -type d -empty -delete || true - - retention_days="${RETENTION_DAYS}" - - seconds=$(( $(date -u +%s) % EVERY)) - (( seconds < 1 )) || sleep $((EVERY - seconds - 1)) - sleep 1 -done -EOF - -# The content below is automatically copied from scripts/docker/airflow-scheduler-autorestart.sh -COPY <<"EOF" /airflow-scheduler-autorestart.sh -#!/usr/bin/env bash - -while echo "Running"; do - airflow scheduler -n 5 - return_code=$? - if (( return_code != 0 )); then - echo "Scheduler crashed with exit code $return_code. Respawning.." >&2 - date >> /tmp/airflow_scheduler_errors.txt - fi - - sleep 1 -done -EOF - -############################################################################################## -# This is the build image where we build all dependencies -############################################################################################## -FROM ${BASE_IMAGE} as airflow-build-image - -# Nolog bash flag is currently ignored - but you can replace it with -# xtrace - to show commands executed) -SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "nounset", "-o", "nolog", "-c"] - -ARG BASE_IMAGE - -# Make sure noninteractive debian install is used and language variables set -ENV BASE_IMAGE=${BASE_IMAGE} \ - DEBIAN_FRONTEND=noninteractive LANGUAGE=C.UTF-8 LANG=C.UTF-8 LC_ALL=C.UTF-8 \ - LC_CTYPE=C.UTF-8 LC_MESSAGES=C.UTF-8 \ - PIP_CACHE_DIR=/tmp/.cache/pip \ - UV_CACHE_DIR=/tmp/.cache/uv - -ARG DEV_APT_DEPS="" -ARG ADDITIONAL_DEV_APT_DEPS="" -ARG DEV_APT_COMMAND="" -ARG ADDITIONAL_DEV_APT_COMMAND="" -ARG ADDITIONAL_DEV_APT_ENV="" -ARG AIRFLOW_PYTHON_VERSION - -ENV DEV_APT_DEPS=${DEV_APT_DEPS} \ - ADDITIONAL_DEV_APT_DEPS=${ADDITIONAL_DEV_APT_DEPS} \ - DEV_APT_COMMAND=${DEV_APT_COMMAND} \ - ADDITIONAL_DEV_APT_COMMAND=${ADDITIONAL_DEV_APT_COMMAND} \ - ADDITIONAL_DEV_APT_ENV=${ADDITIONAL_DEV_APT_ENV} \ - AIRFLOW_PYTHON_VERSION=${AIRFLOW_PYTHON_VERSION} - -ARG PYTHON_LTO - -ENV RUSTUP_HOME="/usr/local/rustup" -ENV CARGO_HOME="/home/airflow/.cargo" -ENV PATH="${CARGO_HOME}/bin:${PATH}" - -COPY --from=scripts install_os_dependencies.sh /scripts/docker/ -COPY scripts/docker/keys/ /scripts/docker/keys/ -RUN PYTHON_LTO=${PYTHON_LTO} bash /scripts/docker/install_os_dependencies.sh dev - -# In case system python is installed, setting LD_LIBRARY_PATH prevents any case the system python -# libraries will be accidentally used before the library installed from sources (which is newer and -# python interpreter might break if accidentally the old system libraries are used. -ENV LD_LIBRARY_PATH="/usr/python/lib" - -ARG INSTALL_MYSQL_CLIENT="false" -ARG INSTALL_MYSQL_CLIENT_TYPE="mariadb" -ARG INSTALL_MSSQL_CLIENT="false" -ARG INSTALL_POSTGRES_CLIENT="true" - -ENV INSTALL_MYSQL_CLIENT=${INSTALL_MYSQL_CLIENT} \ - INSTALL_MYSQL_CLIENT_TYPE=${INSTALL_MYSQL_CLIENT_TYPE} \ - INSTALL_MSSQL_CLIENT=${INSTALL_MSSQL_CLIENT} \ - INSTALL_POSTGRES_CLIENT=${INSTALL_POSTGRES_CLIENT} - -COPY --from=scripts common.sh /scripts/docker/ - -# Only copy mysql/mssql installation scripts for now - so that changing the other -# scripts which are needed much later will not invalidate the docker layer here -COPY --from=scripts install_mysql.sh install_mssql.sh install_postgres.sh /scripts/docker/ - -RUN bash /scripts/docker/install_mysql.sh dev && \ - bash /scripts/docker/install_mssql.sh dev && \ - bash /scripts/docker/install_postgres.sh dev -ENV PATH=${PATH}:/opt/mssql-tools/bin - -# By default we do not install from docker context files but if we decide to install from docker context -# files, we should override those variables to "docker-context-files" -ARG DOCKER_CONTEXT_FILES="Dockerfile" -ARG AIRFLOW_IMAGE_TYPE -ARG AIRFLOW_HOME -ARG AIRFLOW_USER_HOME_DIR -ARG AIRFLOW_UID - -RUN adduser --gecos "First Last,RoomNumber,WorkPhone,HomePhone" --disabled-password \ - --quiet "airflow" --uid "${AIRFLOW_UID}" --gid "0" --home "${AIRFLOW_USER_HOME_DIR}" && \ - mkdir -p ${AIRFLOW_HOME} && chown -R "airflow:0" "${AIRFLOW_USER_HOME_DIR}" ${AIRFLOW_HOME} - -COPY --chown=${AIRFLOW_UID}:0 ${DOCKER_CONTEXT_FILES} /docker-context-files - -USER airflow - -ARG AIRFLOW_REPO=apache/airflow -ARG AIRFLOW_BRANCH=main -ARG AIRFLOW_EXTRAS -ARG ADDITIONAL_AIRFLOW_EXTRAS="" -# Allows to override constraints source -ARG CONSTRAINTS_GITHUB_REPOSITORY="apache/airflow" -ARG AIRFLOW_CONSTRAINTS_MODE="constraints" -ARG AIRFLOW_CONSTRAINTS_REFERENCE="" -ARG AIRFLOW_CONSTRAINTS_LOCATION="" -ARG DEFAULT_CONSTRAINTS_BRANCH="constraints-main" -# By default do not fallback to installation without constraints because it can hide problems with constraints -ARG AIRFLOW_FALLBACK_NO_CONSTRAINTS_INSTALLATION="false" - -# By default PIP has progress bar but you can disable it. -ARG PIP_PROGRESS_BAR -# This is airflow version that is put in the label of the image build -ARG AIRFLOW_VERSION -# By default latest released version of airflow is installed (when empty) but this value can be overridden -# and we can install version according to specification (For example ==2.0.2 or <3.0.0). -ARG AIRFLOW_VERSION_SPECIFICATION -# Determines the way airflow is installed. By default we install airflow from PyPI `apache-airflow` package -# But it also can be `.` from local installation or GitHub URL pointing to specific branch or tag -# Of Airflow. Note That for local source installation you need to have local sources of -# Airflow checked out together with the Dockerfile and AIRFLOW_SOURCES_FROM and AIRFLOW_SOURCES_TO -# set to "." and "/opt/airflow" respectively. -ARG AIRFLOW_INSTALLATION_METHOD="apache-airflow" -# By default we do not upgrade to latest dependencies -ARG UPGRADE_RANDOM_INDICATOR_STRING="" -ARG AIRFLOW_SOURCES_FROM -ARG AIRFLOW_SOURCES_TO - -ENV AIRFLOW_USER_HOME_DIR=${AIRFLOW_USER_HOME_DIR} - -RUN if [[ -f /docker-context-files/pip.conf ]]; then \ - mkdir -p ${AIRFLOW_USER_HOME_DIR}/.config/pip; \ - cp /docker-context-files/pip.conf "${AIRFLOW_USER_HOME_DIR}/.config/pip/pip.conf"; \ - fi; \ - if [[ -f /docker-context-files/.piprc ]]; then \ - cp /docker-context-files/.piprc "${AIRFLOW_USER_HOME_DIR}/.piprc"; \ - fi - -# Additional PIP flags passed to all pip install commands except reinstalling pip itself -ARG ADDITIONAL_PIP_INSTALL_FLAGS="" - -ARG AIRFLOW_PIP_VERSION -ARG AIRFLOW_UV_VERSION -ARG AIRFLOW_USE_UV -ARG INCLUDE_PRE_RELEASE="false" - -ENV AIRFLOW_PIP_VERSION=${AIRFLOW_PIP_VERSION} \ - AIRFLOW_UV_VERSION=${AIRFLOW_UV_VERSION} \ - AIRFLOW_USE_UV=${AIRFLOW_USE_UV} \ - AIRFLOW_VERSION=${AIRFLOW_VERSION} \ - AIRFLOW_INSTALLATION_METHOD=${AIRFLOW_INSTALLATION_METHOD} \ - AIRFLOW_VERSION_SPECIFICATION=${AIRFLOW_VERSION_SPECIFICATION} \ - AIRFLOW_SOURCES_FROM=${AIRFLOW_SOURCES_FROM} \ - AIRFLOW_SOURCES_TO=${AIRFLOW_SOURCES_TO} \ - AIRFLOW_REPO=${AIRFLOW_REPO} \ - AIRFLOW_BRANCH=${AIRFLOW_BRANCH} \ - AIRFLOW_EXTRAS=${AIRFLOW_EXTRAS}${ADDITIONAL_AIRFLOW_EXTRAS:+,}${ADDITIONAL_AIRFLOW_EXTRAS} \ - CONSTRAINTS_GITHUB_REPOSITORY=${CONSTRAINTS_GITHUB_REPOSITORY} \ - AIRFLOW_CONSTRAINTS_MODE=${AIRFLOW_CONSTRAINTS_MODE} \ - AIRFLOW_CONSTRAINTS_REFERENCE=${AIRFLOW_CONSTRAINTS_REFERENCE} \ - AIRFLOW_CONSTRAINTS_LOCATION=${AIRFLOW_CONSTRAINTS_LOCATION} \ - AIRFLOW_FALLBACK_NO_CONSTRAINTS_INSTALLATION=${AIRFLOW_FALLBACK_NO_CONSTRAINTS_INSTALLATION} \ - DEFAULT_CONSTRAINTS_BRANCH=${DEFAULT_CONSTRAINTS_BRANCH} \ - PATH=${AIRFLOW_USER_HOME_DIR}/.local/bin:${PATH} \ - PIP_PROGRESS_BAR=${PIP_PROGRESS_BAR} \ - ADDITIONAL_PIP_INSTALL_FLAGS=${ADDITIONAL_PIP_INSTALL_FLAGS} \ - AIRFLOW_HOME=${AIRFLOW_HOME} \ - AIRFLOW_IMAGE_TYPE=${AIRFLOW_IMAGE_TYPE} \ - AIRFLOW_UID=${AIRFLOW_UID} \ - INCLUDE_PRE_RELEASE=${INCLUDE_PRE_RELEASE} \ - UPGRADE_RANDOM_INDICATOR_STRING=${UPGRADE_RANDOM_INDICATOR_STRING} - - -# Copy all scripts required for installation - changing any of those should lead to -# rebuilding from here -COPY --from=scripts common.sh install_packaging_tools.sh create_prod_venv.sh /scripts/docker/ - -# We can set this value to true in case we want to install .whl/.tar.gz packages placed in the -# docker-context-files folder. This can be done for both additional packages you want to install -# as well as Airflow and provider distributions (it will be automatically detected if airflow -# is installed from docker-context files rather than from PyPI) -ARG INSTALL_DISTRIBUTIONS_FROM_CONTEXT="false" - -# Normally constraints are not used when context packages are build - because we might have packages -# that are conflicting with Airflow constraints, however there are cases when we want to use constraints -# for example in CI builds when we already have source-package constraints - either from github branch or -# from eager-upgraded constraints by the CI builds -ARG USE_CONSTRAINTS_FOR_CONTEXT_DISTRIBUTIONS="false" - -# In case of Production build image segment we want to pre-install main version of airflow -# dependencies from GitHub so that we do not have to always reinstall it from the scratch. -# The Airflow and providers are uninstalled, only dependencies remain -# the cache is only used when "upgrade to newer dependencies" is not set to automatically -# account for removed dependencies (we do not install them in the first place) and in case -# INSTALL_DISTRIBUTIONS_FROM_CONTEXT is not set (because then caching it from main makes no sense). - -# By default PIP installs everything to ~/.local and it's also treated as VIRTUALENV -ENV VIRTUAL_ENV="${AIRFLOW_USER_HOME_DIR}/.local" -ENV PATH="/usr/python/bin:$PATH" -RUN bash /scripts/docker/install_packaging_tools.sh; bash /scripts/docker/create_prod_venv.sh - -COPY --chown=airflow:0 ${AIRFLOW_SOURCES_FROM} ${AIRFLOW_SOURCES_TO} - -# Add extra python dependencies -ARG ADDITIONAL_PYTHON_DEPS="" - - -ARG VERSION_SUFFIX="" - -ENV ADDITIONAL_PYTHON_DEPS=${ADDITIONAL_PYTHON_DEPS} \ - INSTALL_DISTRIBUTIONS_FROM_CONTEXT=${INSTALL_DISTRIBUTIONS_FROM_CONTEXT} \ - USE_CONSTRAINTS_FOR_CONTEXT_DISTRIBUTIONS=${USE_CONSTRAINTS_FOR_CONTEXT_DISTRIBUTIONS} \ - VERSION_SUFFIX=${VERSION_SUFFIX} - -WORKDIR ${AIRFLOW_HOME} - -COPY --from=scripts install_from_docker_context_files.sh install_airflow_when_building_images.sh \ - install_additional_dependencies.sh create_prod_venv.sh get_distribution_specs.py /scripts/docker/ - -# Useful for creating a cache id based on the underlying architecture, preventing the use of cached python packages from -# an incorrect architecture. -ARG TARGETARCH -# Value to be able to easily change cache id and therefore use a bare new cache -ARG DEPENDENCY_CACHE_EPOCH="11" - -# hadolint ignore=SC2086, SC2010, DL3042 -RUN --mount=type=cache,id=prod-$TARGETARCH-$DEPENDENCY_CACHE_EPOCH,target=/tmp/.cache/,uid=${AIRFLOW_UID} \ - if [[ ${INSTALL_DISTRIBUTIONS_FROM_CONTEXT} == "true" ]]; then \ - bash /scripts/docker/install_from_docker_context_files.sh; \ - fi; \ - if ! airflow version 2>/dev/null >/dev/null; then \ - bash /scripts/docker/install_airflow_when_building_images.sh; \ - fi; \ - if [[ -n "${ADDITIONAL_PYTHON_DEPS}" ]]; then \ - bash /scripts/docker/install_additional_dependencies.sh; \ - fi; \ - find "${AIRFLOW_USER_HOME_DIR}/.local/" -name '*.pyc' -print0 | xargs -0 rm -f || true ; \ - find "${AIRFLOW_USER_HOME_DIR}/.local/" -type d -name '__pycache__' -print0 | xargs -0 rm -rf || true ; \ - # make sure that all directories and files in .local are also group accessible - find "${AIRFLOW_USER_HOME_DIR}/.local" -executable ! -type l -print0 | xargs --null chmod g+x; \ - find "${AIRFLOW_USER_HOME_DIR}/.local" ! -type l -print0 | xargs --null chmod g+rw - -# In case there is a requirements.txt file in "docker-context-files" it will be installed -# during the build additionally to whatever has been installed so far. It is recommended that -# the requirements.txt contains only dependencies with == version specification -# hadolint ignore=DL3042 -RUN --mount=type=cache,id=prod-$TARGETARCH-$DEPENDENCY_CACHE_EPOCH,target=/tmp/.cache/,uid=${AIRFLOW_UID} \ - if [[ -f /docker-context-files/requirements.txt ]]; then \ - pip install -r /docker-context-files/requirements.txt; \ - find "${AIRFLOW_USER_HOME_DIR}/.local/" -name '*.pyc' -print0 | xargs -0 rm -f || true ; \ - find "${AIRFLOW_USER_HOME_DIR}/.local/" -type d -name '__pycache__' -print0 | xargs -0 rm -rf || true ; \ - # make sure that all directories and files in .local are also group accessible - find "${AIRFLOW_USER_HOME_DIR}/.local" -executable ! -type l -print0 | xargs --null chmod g+x; \ - find "${AIRFLOW_USER_HOME_DIR}/.local" ! -type l -print0 | xargs --null chmod g+rw; \ - fi - -############################################################################################## -# This is the actual Airflow image - much smaller than the build one. We copy -# installed Airflow and all its dependencies from the build image to make it smaller. -############################################################################################## -FROM ${BASE_IMAGE} as main - -# Nolog bash flag is currently ignored - but you can replace it with other flags (for example -# xtrace - to show commands executed) -SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "nounset", "-o", "nolog", "-c"] - -ARG AIRFLOW_UID - -LABEL org.apache.airflow.distro="debian" \ - org.apache.airflow.module="airflow" \ - org.apache.airflow.component="airflow" \ - org.apache.airflow.image="airflow" \ - org.apache.airflow.uid="${AIRFLOW_UID}" - -ARG BASE_IMAGE - -# Make sure noninteractive debian install is used and language variables set -ENV BASE_IMAGE=${BASE_IMAGE} \ - DEBIAN_FRONTEND=noninteractive LANGUAGE=C.UTF-8 LANG=C.UTF-8 LC_ALL=C.UTF-8 \ - LC_CTYPE=C.UTF-8 LC_MESSAGES=C.UTF-8 \ - PIP_CACHE_DIR=/tmp/.cache/pip \ - UV_CACHE_DIR=/tmp/.cache/uv - -ARG RUNTIME_APT_DEPS="" -ARG ADDITIONAL_RUNTIME_APT_DEPS="" -ARG RUNTIME_APT_COMMAND="echo" -ARG ADDITIONAL_RUNTIME_APT_COMMAND="" -ARG ADDITIONAL_RUNTIME_APT_ENV="" -ARG INSTALL_MYSQL_CLIENT="true" -ARG INSTALL_MYSQL_CLIENT_TYPE="mariadb" -ARG INSTALL_MSSQL_CLIENT="true" -ARG INSTALL_POSTGRES_CLIENT="true" -ARG AIRFLOW_INSTALLATION_METHOD="apache-airflow" - -ENV RUNTIME_APT_DEPS=${RUNTIME_APT_DEPS} \ - ADDITIONAL_RUNTIME_APT_DEPS=${ADDITIONAL_RUNTIME_APT_DEPS} \ - RUNTIME_APT_COMMAND=${RUNTIME_APT_COMMAND} \ - ADDITIONAL_RUNTIME_APT_COMMAND=${ADDITIONAL_RUNTIME_APT_COMMAND} \ - INSTALL_MYSQL_CLIENT=${INSTALL_MYSQL_CLIENT} \ - INSTALL_MYSQL_CLIENT_TYPE=${INSTALL_MYSQL_CLIENT_TYPE} \ - INSTALL_MSSQL_CLIENT=${INSTALL_MSSQL_CLIENT} \ - INSTALL_POSTGRES_CLIENT=${INSTALL_POSTGRES_CLIENT} \ - GUNICORN_CMD_ARGS="--worker-tmp-dir /dev/shm" \ - AIRFLOW_INSTALLATION_METHOD=${AIRFLOW_INSTALLATION_METHOD} - -ARG PYTHON_LTO - -COPY --from=airflow-build-image "/usr/python/" "/usr/python/" -COPY --from=scripts install_os_dependencies.sh /scripts/docker/ -RUN bash /scripts/docker/install_os_dependencies.sh runtime - -# Having the variable in final image allows to disable providers manager warnings when -# production image is prepared from sources rather than from package -ARG AIRFLOW_IMAGE_REPOSITORY -ARG AIRFLOW_IMAGE_README_URL -ARG AIRFLOW_USER_HOME_DIR -ARG AIRFLOW_HOME -ARG AIRFLOW_IMAGE_TYPE - -# By default PIP installs everything to ~/.local -ENV PATH="${AIRFLOW_USER_HOME_DIR}/.local/bin:/usr/python/bin:${PATH}" \ - VIRTUAL_ENV="${AIRFLOW_USER_HOME_DIR}/.local" \ - AIRFLOW_UID=${AIRFLOW_UID} \ - AIRFLOW_USER_HOME_DIR=${AIRFLOW_USER_HOME_DIR} \ - AIRFLOW_HOME=${AIRFLOW_HOME} \ - AIRFLOW_IMAGE_TYPE=${AIRFLOW_IMAGE_TYPE} - -COPY --from=scripts common.sh /scripts/docker/ - -COPY scripts/docker/keys/ /scripts/docker/keys/ - -# Only copy mysql/mssql installation scripts for now - so that changing the other -# scripts which are needed much later will not invalidate the docker layer here. -COPY --from=scripts install_mysql.sh install_mssql.sh install_postgres.sh /scripts/docker/ -# We run scripts with bash here to make sure we can execute the scripts. Changing to +x might have an -# unexpected result - the cache for Dockerfiles might get invalidated in case the host system -# had different umask set and group x bit was not set. In Azure the bit might be not set at all. -# That also protects against AUFS Docker backend problem where changing the executable bit required sync -RUN bash /scripts/docker/install_mysql.sh prod \ - && bash /scripts/docker/install_mssql.sh prod \ - && bash /scripts/docker/install_postgres.sh prod \ - && adduser --gecos "First Last,RoomNumber,WorkPhone,HomePhone" --disabled-password \ - --quiet "airflow" --uid "${AIRFLOW_UID}" --gid "0" --home "${AIRFLOW_USER_HOME_DIR}" \ -# Make Airflow files belong to the root group and are accessible. This is to accommodate the guidelines from -# OpenShift https://docs.openshift.com/enterprise/3.0/creating_images/guidelines.html - && mkdir -pv "${AIRFLOW_HOME}" \ - && mkdir -pv "${AIRFLOW_HOME}/dags" \ - && mkdir -pv "${AIRFLOW_HOME}/logs" \ - && chown -R airflow:0 "${AIRFLOW_USER_HOME_DIR}" "${AIRFLOW_HOME}" \ - && chmod -R g+rw "${AIRFLOW_USER_HOME_DIR}" "${AIRFLOW_HOME}" \ - && find "${AIRFLOW_USER_HOME_DIR}" -name '*.pyc' -print0 | xargs -0 rm -f || true \ - && find "${AIRFLOW_USER_HOME_DIR}" -type d -name '__pycache__' -print0 | xargs -0 rm -rf || true \ - && find "${AIRFLOW_HOME}" -executable ! -type l -print0 | xargs --null chmod g+x \ - && find "${AIRFLOW_USER_HOME_DIR}" -executable ! -type l -print0 | xargs --null chmod g+x - -ARG AIRFLOW_SOURCES_FROM -ARG AIRFLOW_SOURCES_TO - -COPY --from=airflow-build-image --chown=airflow:0 \ - "${AIRFLOW_USER_HOME_DIR}/.local" "${AIRFLOW_USER_HOME_DIR}/.local" -COPY --from=airflow-build-image --chown=airflow:0 \ - "${AIRFLOW_USER_HOME_DIR}/constraints.txt" "${AIRFLOW_USER_HOME_DIR}/constraints.txt" -# In case of editable build also copy airflow sources so that they are available in the main image -# For regular image (non-editable) this will be just Dockerfile copied to /Dockerfile -COPY --from=airflow-build-image --chown=airflow:0 "${AIRFLOW_SOURCES_TO}" "${AIRFLOW_SOURCES_TO}" - -COPY --from=scripts entrypoint_prod.sh /entrypoint -COPY --from=scripts clean-logs.sh /clean-logs -COPY --from=scripts airflow-scheduler-autorestart.sh /airflow-scheduler-autorestart - -# Make /etc/passwd root-group-writeable so that user can be dynamically added by OpenShift -# See https://github.com/apache/airflow/issues/9248 -# Set default groups for airflow and root user - -RUN chmod a+rx /entrypoint /clean-logs \ - && chmod g=u /etc/passwd \ - && chmod g+w "${AIRFLOW_USER_HOME_DIR}/.local" \ - && usermod -g 0 airflow -G 0 - -# make sure that the venv is activated for all users -# including plain sudo, sudo with --interactive flag -RUN sed --in-place=.bak "s/secure_path=\"/secure_path=\"$(echo -n ${AIRFLOW_USER_HOME_DIR} | \ - sed 's/\//\\\//g')\/.local\/bin:/" /etc/sudoers - -ARG AIRFLOW_VERSION -ARG AIRFLOW_PIP_VERSION -ARG AIRFLOW_UV_VERSION -ARG AIRFLOW_USE_UV -ARG AIRFLOW_PYTHON_VERSION - -# See https://airflow.apache.org/docs/docker-stack/entrypoint.html#signal-propagation -# to learn more about the way how signals are handled by the image -# Also set airflow as nice PROMPT message. -ENV DUMB_INIT_SETSID="1" \ - PS1="(airflow)" \ - AIRFLOW_VERSION=${AIRFLOW_VERSION} \ - AIRFLOW_PYTHON_VERSION=${AIRFLOW_PYTHON_VERSION} \ - AIRFLOW__CORE__LOAD_EXAMPLES="false" \ - PATH="/root/bin:${PATH}" \ - AIRFLOW_PIP_VERSION=${AIRFLOW_PIP_VERSION} \ - AIRFLOW_UV_VERSION=${AIRFLOW_UV_VERSION} \ - AIRFLOW_USE_UV=${AIRFLOW_USE_UV} - -# Add protection against running pip as root user -RUN mkdir -pv /root/bin -COPY --from=scripts pip /root/bin/pip -RUN chmod u+x /root/bin/pip - -WORKDIR ${AIRFLOW_HOME} - -EXPOSE 8080 - -USER ${AIRFLOW_UID} - -# Those should be set and used as late as possible as any change in commit/build otherwise invalidates the -# layers right after -ARG BUILD_ID -ARG COMMIT_SHA -ARG AIRFLOW_IMAGE_REPOSITORY -ARG AIRFLOW_IMAGE_DATE_CREATED - -ENV BUILD_ID=${BUILD_ID} COMMIT_SHA=${COMMIT_SHA} - -LABEL org.apache.airflow.distro="debian" \ - org.apache.airflow.module="airflow" \ - org.apache.airflow.component="airflow" \ - org.apache.airflow.image="airflow" \ - org.apache.airflow.version="${AIRFLOW_VERSION}" \ - org.apache.airflow.python.version="${AIRFLOW_PYTHON_VERSION}" \ - org.apache.airflow.uid="${AIRFLOW_UID}" \ - org.apache.airflow.main-image.build-id="${BUILD_ID}" \ - org.apache.airflow.main-image.commit-sha="${COMMIT_SHA}" \ - org.opencontainers.image.source="${AIRFLOW_IMAGE_REPOSITORY}" \ - org.opencontainers.image.created=${AIRFLOW_IMAGE_DATE_CREATED} \ - org.opencontainers.image.authors="dev@airflow.apache.org" \ - org.opencontainers.image.url="https://airflow.apache.org" \ - org.opencontainers.image.documentation="https://airflow.apache.org/docs/docker-stack/index.html" \ - org.opencontainers.image.version="${AIRFLOW_VERSION}" \ - org.opencontainers.image.revision="${COMMIT_SHA}" \ - org.opencontainers.image.vendor="Apache Software Foundation" \ - org.opencontainers.image.licenses="Apache-2.0" \ - org.opencontainers.image.ref.name="airflow" \ - org.opencontainers.image.title="Production Airflow Image" \ - org.opencontainers.image.description="Reference, production-ready Apache Airflow image" - -ENTRYPOINT ["/usr/bin/dumb-init", "--", "/entrypoint"] -CMD [] diff --git a/docker/airflow-base/README.md b/docker/airflow-base/README.md deleted file mode 100644 index c5e68570..00000000 --- a/docker/airflow-base/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Apache Airflow base image (Debian Trixie) - -Apache only publishes `apache/airflow` images based on Debian **bookworm**. We need -Trixie because GDAL >= 3.13 from conda-forge (installed in `docker/Dockerfile.airflow`) -links against the *system* libstdc++ and requires `GLIBCXX_3.4.31` / `CXXABI_1.3.15`, -i.e. GCC 13+. Bookworm ships `libstdc++6` 12.2, so `ogr2ogr` installs fine there but -fails to load at runtime; Trixie ships 14.2, which is compatible. - -So we build the base image ourselves from the official Airflow Dockerfile on top of a -`debian:*-slim` base image (build arg `BASE_IMAGE`). - -## Contents -- `Dockerfile` — the official Airflow Dockerfile, tag `3.2.2` - (https://raw.githubusercontent.com/apache/airflow/3.2.2/Dockerfile), plus the local - patches listed under [Trixie patch](#trixie-patch). -- `scripts/docker/keys/` — apt/Python signing keys referenced by the Dockerfile, - copied from the same tag. - -## Build - -```bash -make build-airflow-base -# equivalent to: -docker build \ - --build-arg BASE_IMAGE=debian:trixie-slim \ - --build-arg AIRFLOW_VERSION=3.2.2 \ - -t datafeeder-airflow-base:3.2.2-trixie \ - docker/airflow-base -``` - -Note that `AIRFLOW_PYTHON_VERSION` has no effect: the patched `install_python()` takes -Python from apt (see below), so the image gets whatever Trixie ships — currently 3.13.5, -which satisfies the workspace's `requires-python = "==3.13.*"`. Bumping the workspace to -3.14 therefore requires more than a build arg, since Trixie has no `python3.14` package. - -The resulting `datafeeder-airflow-base:3.2.2-trixie` image is consumed as the `base` -stage of `docker/Dockerfile.airflow` (build arg `AIRFLOW_BASE_IMAGE`). - -## Upgrading -1. Download the official Dockerfile and `scripts/docker/keys/` for the new tag. -2. Re-apply the three patches below. -3. Bump `AIRFLOW_VERSION` in the `Makefile` and `apps/elt/pyproject.toml`, then - regenerate `apps/elt/uv.lock`. - -## Trixie patch -Three changes are applied to the upstream Dockerfile (re-apply them when refreshing -from upstream): -- `install_python()` rewritten to install Python from apt instead of building it from - source, with an early `return 0` keeping the original body below it as dead code. - This is why `AIRFLOW_PYTHON_VERSION` is ignored and the Python version is whatever - Trixie packages. -- removed `lzma-dev` from `DEV_APT_DEPS`: bookworm-only transitional package, gone on - Trixie and fully covered by `liblzma-dev`. -- removed `lcov` from `DEV_APT_DEPS`: on Trixie it pulls in the system Python - (`libpython3.13`). It is only a coverage tool and is not needed to build/run the - image. diff --git a/docker/airflow-base/scripts/docker/keys/mariadb.asc b/docker/airflow-base/scripts/docker/keys/mariadb.asc deleted file mode 100644 index d35c5a49..00000000 --- a/docker/airflow-base/scripts/docker/keys/mariadb.asc +++ /dev/null @@ -1,104 +0,0 @@ ------BEGIN PGP PUBLIC KEY BLOCK----- - -xsFNBFb8EKsBEADwGmleOSVThrbCyCVUdCreMTKpmD5p5aPz/0jc66050MAb71Hv -TVcfuMqHYO8O66qXLpEdqZpuk4D+rw1oKyC+d8uPD2PSHRqBXnR0Qf+LVTZvtO92 -3R7pYnC2x6V6iVGpKQYFP8cwh2B1qgIa+9y/N8cQIqfD+0ghyiUjjTYek3YFBnqa -L/2h2V0Mt0DkBrDK80LqEY10PAFDfJjINAW9XNHZzi2KqUx5w1z8rItokXV6fYE5 -ItyGMR6WVajJg5D4VCiZd0ymuQP2bGkrRbl6FH5vofVSkahKMJeHs2lbvMvNyS3c -n8vxoBvbbcwSAV1gvB1uzXXxv0kdkFZjhU1Tss4+Dak8qeEmIrC5qYycLxIdVEhT -Z8N8+P7Dll+QGOZKu9+OzhQ+byzpLFhUHKys53eXo/HrfWtw3DdP21yyb5P3QcgF -scxfZHzZtFNUL6XaVnauZM2lqquUW+lMNdKKGCBJ6co4QxjocsxfISyarcFj6ZR0 -5Hf6VU3Y7AyuFZdL0SQWPv9BSu/swBOimrSiiVHbtE49Nx1x/d1wn1peYl07WRUv -C10eF36ZoqEuSGmDz59mWlwB3daIYAsAAiBwgcmN7aSB8XD4ZPUVSEZvwSm/IwuS -Rkpde+kIhTLjyv5bRGqU2P/Mi56dB4VFmMJaF26CiRXatxhXOAIAF9dXCwARAQAB -zS1NYXJpYURCIFNpZ25pbmcgS2V5IDxzaWduaW5nLWtleUBtYXJpYWRiLm9yZz7C -wXgEEwEIACIFAlb8EKsCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEPFl -byTHTNHYJZ0P/2Z2RURRkSTHLKZ/GqSvPReReeB7AI+ZrDapkpG/26xp1Yw1isCO -y99pvQ7hjTFhdZQ7xSRUiT/e27wJxR7s4G/ck5VOVjuJzGnByNLmwMjdN1ONIO9P -hQAs2iF3uoIbVTxzXof2F8C0WSbKgEWbtqlCWlaapDpN8jKAWdsQsNMdXcdpJ2os -WiacQRxLREBGjVRkAiqdjYkegQ4BZ0GtPULKjZWCUNkaat51b7O7V19nSy/T7MM7 -n+kqYQLMIHCF8LGd3QQsNppRnolWVRzXMdtR2+9iI21qv6gtHcMiAg6QcKA7halL -kCdIS2nWR8g7nZeZjq5XhckeNGrGX/3w/m/lwczYjMUer+qs2ww5expZJ7qhtSta -lE3EtL/l7zE4RlknqwDZ0IXtxCNPu2UovCzZmdZm8UWfMSKk/3VgL8HgzYRr8fo0 -yj0XkckJ7snXvuhoviW2tjm46PyHPWRKgW4iEzUrB+hiXpy3ikt4rLRg/iMqKjyf -mvcE/VdmFVtsfbfRVvlaWiIWCndRTVBkAaTu8DwrGyugQsbjEcK+4E25/SaKIJIw -qfxpyBVhru21ypgEMAw1Y8KC7KntB7jzpFotE4wpv1jZKUZuy71ofr7g3/2O+7nW -LrR1mncbuT6yXo316r56dfKzOxQJBnYFwTjXfa65yBArjQBUCPNYOKr0wkYEEhEI -AAYFAlb8JFYACgkQy8sIKhu5Q9snYACgh3id41CYTHELOQ/ymj4tiuFt1lcAn3JU -9wH3pihM9ISvoeuGnwwHhcKnwsFcBBIBCAAGBQJW/CSEAAoJEJFxGJmV5Fqe11cP -/A3QhvqleuRaXoS5apIY3lrDL79Wo0bkydM3u2Ft9EqVVG5zZvlmWaXbw5wkPhza -7YUjrD7ylaE754lHI48jJp3KY7RosClY/Kuk56GJI/SoMKx4v518pAboZ4hjY9MY -gmiAuZEYx5Ibv1pj0+hkzRI78+f6+d5QTQ6y/35ZjSSJcBgCMAr/JRsmOkHu6cY6 -qOpq4g8mvRAX5ivRm4UxE2gnxZyd2LjY2/S2kCZvHWVaZuiTD0EU1jYPoOo6fhc8 -zjs5FWS56C1vp7aFOGBvsH3lwYAYi1K2S+/B4nqpitYJz/T0zFzzyYe7ZG77DXKD -/XajD22IzRGKjoeVPFBx+2V0YCCpWZkqkfZ2Dt3QVW//QIpVsOJnmaqolDg1sxoa -BEYBtCtovU0wh1pXWwfn7IgjIkPNl0AU8mW8Ll91WF+Lss/oMrUJMKVDenTJ6/ZO -06c+JFlP7dS3YGMsifwgy5abA4Xy4GWpAsyEM68mqsJUc7ZANZcQAKr6+DryzSfI -Olsn3kJzOtb/c3JhVmblEO6XzdfZJK/axPOp3mF1oEBoJ56fGwO2usgVwQDyLt3J -iluJrCvMSBL9KtBZWrTZH5t3rTMN0NUALy4Etd6Y8V94i8c5NixMDyjRU7aKJAAw -tUvxLd12dqtaXsuvGyzLbR4EDT/Q5DfLC1DZWpgtUtCVwsFcBBIBCAAGBQJW/CS2 -AAoJEEHdwLQNpW8iMUoP/AjFKyZ+inQTI2jJJBBtrLjxaxZSG5ggCovowWn8NWv6 -bQBm2VurYVKhvY1xUyxoLY8KN+MvoeTdpB3u7z+M6x+CdfoTGqWQ2yapOC0eEJBF -O+GFho2WE0msiO0IaVJrzdFTPE0EYR2BHziLu0DDSZADe1WYEqkkrZsCNgi6EMng -mX2h+DK2GlC3W2tY9sc63DsgzjcMBO9uYmpHj6nizsIrETqouVNUCLT0t8iETa25 -Mehq/I92I70Qfebv7R4eMrs+tWXKyPU0OjV+8b8saZsv1xn98UkeXwYx4JI04OTw -nBeJG8yPrGDBO5iucmtaCvwGQ3c76qBivrA8eFz3azRxQYWWiFrkElTg+C/E83JQ -WgqPvPZkI5UHvBwBqcoIXG15AJoXA/ZWIB8nPKWKaV5KDnY3DBuA4rh5Mhy3xwcC -/22E/CmZMXjUUvDnlPgXCYAYU0FBbGk7JpSYawtNfdAN2XBRPq5sDKLLxftx7D8u -ESJXXAlPxoRh7x1ArdGM+EowlJJ0xpINBaT0Z/Hk0jxNIFEak796/WeGqewdOIki -dAs4tppUfzosla5K+qXfWwmhcKmpwA4oynE8wIaoXptoi8+rxaw4N6wAXlSrVxeC -VTnb7+UY/BT2Wx6IQ10C9jrsj6XIffMvngIinCD9Czvadmr7BEIxKt1LP+gGA8Zg -wsFcBBIBCgAGBQJYE6oDAAoJEL7YRJ/O6NqIJ24P+QFNa2O+Q1rLKrQiuPw4Q73o -7/blUpFNudZfeCDpDbUgJ01u1RHnWOyLcyknartAosFDJIpgcXY5I8jsBIO5IZPR -C/UKxZB3RYOhj49bySD9RNapHyq+Y56j9JUoz6tkKFBd+6g85Ej8d924xM1UnRCS -9cfI9W0fSunbCi2CXLbXFF7V+m3Ou1SVYGIAxpMn4RXyYfuqeB5wROR2GA5Ef6T3 -S5byh1dRSEgnrBToENtp5n7Jwsc9pDofjtaUkO854l45IqFarGjCHZwtNRKd2lcK -FMnd1jS0nfGkUbn3qNJam1qaGWx4gXaT845VsYYVTbxtkKi+qPUIoOyYx4NEm6fC -ZywH72oP+fmUT/fbfSHa5j137dRqokkR6RFjnEMBl6WHwgqqUqeIT6t9uV6WWzX9 -lNroZFAFL/de7H31iIRuZcm38DUZOfjVf9glweu4yFvuJ7cQtyQydFQJV4LGDT/C -8e9TWrV1/gWMyMGQlZsRWa+h+FfFUccQtfSdXpvSxtXfop+fVQmJgUUl92jh4K9j -c9a6rIp5v1Q1yEgs2iS50/V/NMSmEcE1XMOxFt9fX9T+XmKAWZ8L25lpILsHT3mB -VWrpHdbawUaiBp9elxhn6tFiTFR7qA7dlUyWrI+MMlINwSZ2AAXvmA2IajH/UIlh -xotxmSNiZYIQ6UbD3fk4wsFzBBABCgAdFiEEmy/52H2krRdju+d2+GQcuhDvLUgF -Ally44wACgkQ+GQcuhDvLUgkjQ//c3mBxfJm6yLAJD4s4OgsPv4pcp/EKmPcdztm -W0/glwopUZmq9oNo3VMMCGtusrQgpACzfUlesu9NWlPCB3olZkeGugygo0zuQBKs -55eG7bPzMLyfSqLKyogYocaGc4lpf4lbvlvxy37YGVrGpwT9i8t2REtM6iPKDcMM -sgVtNlqFdq3Fs2Haqt0m1EksX6/GSIrjK4LZEcPklrGPvUS3S+qkwuaGE/jXxncE -4jFQR9SYH6AHr6Vkt1CG9Dgpr+Ph0I9n0JRknBYoUZ1q51WdF946NplXkCskdzWG -RHgMUCz3ZehF1FzpKgfO9Zd0YZsmivV/g6frUw/TayP9gxKPt7z2Lsxzyh8X7cg6 -TAvdG9JbG0PyPJT1TZ8qpjP/PtqPclHsHQQIbGSDFWzRM5znhS+5sgyw8FWInjw8 -JjxoOWMa50464EfGeb2jZfwtRimJAJLWEf/JnvO779nXf5YbvUZgfXaX7k/cvCVk -U8M7oC7x8o6F0P2Lh6FgonklKEeIRtZBUNZ0Lk9OShVqlU9/v16MHq/Eyu/Mbs0D -en3vYgiYxOBR8czD1Wh4vsKiGfOzQ6oWti/DCURV+iTYhJc7mSWM6STzUFr0nCnF -x6W0j/zH6ZgiFAGOyIXW2DwfjFvYRcBL1RWAEKsiFwYrNV+MDonjKXjpVB1Ra90o -lLrZXAXCwHMEEgEKAB0WIQRMRw//78TT3Fl3hlXOGj3V48lPSQUCXAAgOgAKCRDO -Gj3V48lPSQxAB/43qoWteVZEiN3JW4FnHg+S60TnHSP69FKV+363XYKDa23pNpv4 -tiJumo9Kvb4UoDft766/URHm5RKyPtrxy+wqotamrkGJUTtP2a68h7C31VX+pf6i -iQKmxRQz4zmW0pA5X01+AgpvcDH++Fv5NLBpnjqPdTh5b0gvr89E0zMNldNYOZu1 -0H/mukrnGlFDu/osBuy+XJtP2MeasazVMLvjKs+hr//E+iLI9DZOwFBK6AX5gkkI -UEHkSeb4//AHwvanUMin9un9+F9iR+qDuDEKxuevYzM0owuoVcK5pAsRnRQJlnHW -/0BQ6FtNGpmljhvUk8a/l3xFf3z/uJG5vVKVzsFNBFb8EKsBEADDfCMsu2U1CdJh -r4xp6z4J89/tMnpCQASC8DQhtZ6bWG/ksyKt2DnDQ050XBEng+7epzHWA2UgT0li -Y05zZmFs1X7QeZr16B7JANq6fnHOdZB0ThS7JEYbProkMxcqAFLAZJCpZT534Gpz -W7qHwzjV+d13IziCHdi6+DD5eavYzBqY8QzjlOXbmIlY7dJUCwXTECUfirc6kH86 -CS8fXZTke4QYZ55VnrOomB4QGqP371kwBETnhlhi74+pvi3jW05Z5x1tVMwuugyz -zkseZp1VYmJq5SHNFZ/pnAQLE9gUDTb6UWcPBwQh9Sw+7ahSK74lJKYm3wktyvZh -zAxbNyzs1M56yeFP6uFwJTBfNByyMAa6TGUhNkxlLcYjxKbVmoAnKCVM8t41TlLv -/a0ki8iQxqvphVLufksR9IpN6d3F15j6GeyVtxBEv04iv4vbuKthWytb+gjX4bI8 -CAo9jGHevmtdiw/SbeKx2YBM1MF6eua37rFMooOBj4X7VfQCyS+crNsOQn8nJGah -YbzUDCCgnX+pqN9iZvXisMS79wVyD5DyISFDvT/5jY7IXxPibxr10P/8lfW1d72u -xyI2UiZKZpyHCt4k47yMq4KQGLGuhxJ6q6O3bi2aXRuz8bLqTBLca9dmx9wZFvRh -6jS/SKEg7eFcY0xbb6RVIv1UwGDYfQARAQABwsFfBBgBCAAJBQJW/BCrAhsMAAoJ -EPFlbyTHTNHYEBIQAJhFTh1u34Q+5bnfiM2dAdCr6T6w4Y1v9ePiIYdSImeseJS2 -yRglpLcMjW0uEA9KXiRtC/Nm/ClnqYJzCKeIaweHqH6dIgJKaXZFt1Uaia7X9tDD -wqALGu97irUrrV1Kh9IkM0J29Vid5amakrdS4mwt2uEISSnCi7pfVoEro+S7tYQ9 -iH6APVIwqWvcaty3cANdwKWfUQZ6a9IQ08xqzaMhMp2VzhVrWkq3B0j2aRoZR7BN -LH2I7Z0giIM8ARjZs99aTRL+SfMEQ3sUxNLb3KWP/n1lSFbrk4HGzqUBBfczESlN -c0970C6znK0H0HD11/3BTkMuPqww+Tzex4dpMQllMEKZ3wEyd9v6ba+nj/P1FHSE -y/VN6IXzd82s1lYOonKTdmXAIROcHnb0QUzwsd/mhB3jKhEDOV2ZcBTD3yHv8m7C -9G9y4hV+7yQlnPlSg3DjBp3SS5r+sOObCIy2Ad32upoXkilWa9g7GZSuhY9kyKqe -Eba1lgXXaQykEeqx0pexkWavNnb9JaPrAZHDjUGcXrREmjEyXyElRoD4CrWXySe4 -6jCuNhVVlkLGo7osefynXa/+PNjQjURtx8en7M9A1FkQuRAxE8KIZgZzYxkGl5o5 -POSFCA4JUoRPDcrl/sI3fuq2dIOE/BJ2r8dV+LddiR+iukhXRwJXH8RVVEUS -=mCOI ------END PGP PUBLIC KEY BLOCK----- diff --git a/docker/airflow-base/scripts/docker/keys/microsoft.asc b/docker/airflow-base/scripts/docker/keys/microsoft.asc deleted file mode 100644 index 0c8be68d..00000000 --- a/docker/airflow-base/scripts/docker/keys/microsoft.asc +++ /dev/null @@ -1,42 +0,0 @@ ------BEGIN PGP PUBLIC KEY BLOCK----- - -mQINBGVUhiwBEADF3TWX0HMi2+BdQfJrSdQkZTE4qk4vV2ooAMn8vWA2DGI88JOl -k1LwhZGEqJv5TsKTyNEMWb3NXhR1ZZ5uQPvf6iN0806cq83s096F85GUtjzfGLQj -Zo3FhDSKeHz3mhthQ4QP4bwYUmSpWs6e+/ZSFYYc3yU8mInDM4SNzrqr4x2ltmf+ -3RWkoYYo1SpG521A9+1zi7xzz6IHpAk6MdIcTj7mHxXd6ovmXkvHUhKbXGkybHPn -iupWokDaJZgV4+q6kc7zVgTVnwmXV7NHQhWSyOm/BmYVcpmrkCSgSH18SArFjR6Q -KyJ9VuUo1mJEUGnEakQSaOn1UAYtO8Mh4cXXD4833G0BLjiFNOL0XRUNh35pKvcT -my/HnXvRXtpzAzTtANPxIbjli/veagU+JRWhtjtfONz0wQ5Bv1zFjnM9ewxFNPPo -7Jp9WCVeUKFZcZJo8r/k7Y4d0Y1WINOPniSCNhKcD0pva3gXLcxfdnZjdMSj++ba -XlAstjw0Oyty0EXoHXCMpelMoa+DQ7KSDGKrOtm5YFAP6Ki4go1Tt2q8nmul36cZ -Zot6eoPG/qKxW+dvmSrWhQCcfd74VbhECbzXiCFLHadq85C1K5rrLM6oVr1u7K6O -jlc1aitGgZECi6fvu61QhpUvHjCegRWzMIhah9qrv4lvxFFcA+a1jwXlnwARAQAB -tEJNaWNyb3NvZnQgQ29ycG9yYXRpb24gLSBHZW5lcmFsIEdQRyBTaWduZXIgPGdw -Z3NpZ25AbWljcm9zb2Z0LmNvbT6JAjgEEwEIACIFAmVUhiwCGwMGCwkIBwMCBhUI -AgkKCwQWAgMBAh4BAheAAAoJEO5Nd5L3SBgrDc0P/0Ubx0vqD/DgyhiP0bIs8euO -iA5BQvOCiroIkhSkFbAw8rT9a/XtRTRM2l4I8c2M1ZX9i/0wWihmFUJhiVHyRxkl -ZcEFv+ieBuhvD1gPOVLZg3To8yOTrcOnHe+FuKqA6u+3xBn2AmAWeck9o0NKhtnm -5ckweos+Qj9NoxaZX8UeGFstOiTBJeyhuJjthQ+3M0BvTxEaRcLXGSXSGSgZ00ii -YSLNgOMPF+C22bXBL/erClEYkIGCctqPvyrhV/GVNnGk2ALyJqdK+BaJeGh9mBJa -ZrP3l6vFxsAI0RNCNU1s5QaFzfFzFkiUnG/aoyuwh4xmsB+uyVkR+KigPK9gfF3S -nU7AqcdhSbUA6A0DGDRkHauHM5Wtc7730LdjiNDXbYwG/yXmDYNasoszmItZzh77 -HiQxYA5dNB9r9QJS2rHV/qe+heAJ5Rub5kxcu33DGL30qG7Q9+HRTu0oSEOIUFyT -aOJJnNUiB2D4hoKKnr5U8FYOZ7KvDcG7cDvInqYtGpNfrnIf94VeB9WJY6DbDQSA -F5yHb6X8FS0x3lMT2H1l6RRyr0278kyO18VBudtlnonC+Y1UT7eqAk6WjS5CitPX -T3Hc7jCURugXrc51igKa+p67yAaybEIuVyWF6JaINKRqiUqEPVXnHELXPbBmiHW5 -1HwdbKTMzgF8bu1JI+tQmQENBFYxWIwBCADAKoZhZlJxGNGWzqV+1OG1xiQeoowK -hssGAKvd+buXCGISZJwTLXZqIcIiLP7pqdcZWtE9bSc7yBY2MalDp9Liu0KekywQ -6VVX1T72NPf5Ev6x6DLV7aVWsCzUAF+eb7DC9fPuFLEdxmOEYoPjzrQ7cCnSV4JQ -xAqhU4T6OjbvRazGl3agOeizPXmRljMtUUttHQZnRhtlzkmwIrUivbfFPD+fEoHJ -1+uIdfOzZX8/oKHKLe2jH632kvsNzJFlROVvGLYAk2WRcLu+RjjggixhwiB+Mu/A -8Tf4V6b+YppS44q8EvVrM+QvY7LNSOffSO6Slsy9oisGTdfE39nC7pVRABEBAAG0 -N01pY3Jvc29mdCAoUmVsZWFzZSBzaWduaW5nKSA8Z3Bnc2VjdXJpdHlAbWljcm9z -b2Z0LmNvbT6JATQEEwEIAB4FAlYxWIwCGwMGCwkIBwMCAxUIAwMWAgECHgECF4AA -CgkQ6z6Urb4SKc+P9gf/diY2900wvWEgV7iMgrtGzx79W/PbwWiOkKoD9sdzhARX -WiP8Q5teL/t5TUH6TZ3BENboDjwr705jLLPwuEDtPI9jz4kvdT86JwwG6N8gnWM8 -Ldi56SdJEtXrzwtlB/Fe6tyfMT1E/PrJfgALUG9MWTIJkc0GhRJoyPpGZ6YWSLGX -nk4c0HltYKDFR7q4wtI84cBu4mjZHZbxIO6r8Cci+xxuJkpOTIpr4pdpQKpECM6x -5SaT2gVnscbN0PE19KK9nPsBxyK4wW0AvAhed2qldBPTipgzPhqB2gu0jSryil95 -bKrSmlYJd1Y1XfNHno5Dxfn5JwgySBIdWWvtOI05gw== -=iQlr ------END PGP PUBLIC KEY BLOCK----- diff --git a/docker/airflow-base/scripts/docker/keys/postgres.asc b/docker/airflow-base/scripts/docker/keys/postgres.asc deleted file mode 100644 index 8480576e..00000000 --- a/docker/airflow-base/scripts/docker/keys/postgres.asc +++ /dev/null @@ -1,77 +0,0 @@ ------BEGIN PGP PUBLIC KEY BLOCK----- - -mQINBE6XR8IBEACVdDKT2HEH1IyHzXkb4nIWAY7echjRxo7MTcj4vbXAyBKOfjja -UrBEJWHN6fjKJXOYWXHLIYg0hOGeW9qcSiaa1/rYIbOzjfGfhE4x0Y+NJHS1db0V -G6GUj3qXaeyqIJGS2z7m0Thy4Lgr/LpZlZ78Nf1fliSzBlMo1sV7PpP/7zUO+aA4 -bKa8Rio3weMXQOZgclzgeSdqtwKnyKTQdXY5MkH1QXyFIk1nTfWwyqpJjHlgtwMi -c2cxjqG5nnV9rIYlTTjYG6RBglq0SmzF/raBnF4Lwjxq4qRqvRllBXdFu5+2pMfC -IZ10HPRdqDCTN60DUix+BTzBUT30NzaLhZbOMT5RvQtvTVgWpeIn20i2NrPWNCUh -hj490dKDLpK/v+A5/i8zPvN4c6MkDHi1FZfaoz3863dylUBR3Ip26oM0hHXf4/2U -A/oA4pCl2W0hc4aNtozjKHkVjRx5Q8/hVYu+39csFWxo6YSB/KgIEw+0W8DiTII3 -RQj/OlD68ZDmGLyQPiJvaEtY9fDrcSpI0Esm0i4sjkNbuuh0Cvwwwqo5EF1zfkVj -Tqz2REYQGMJGc5LUbIpk5sMHo1HWV038TWxlDRwtOdzw08zQA6BeWe9FOokRPeR2 -AqhyaJJwOZJodKZ76S+LDwFkTLzEKnYPCzkoRwLrEdNt1M7wQBThnC5z6wARAQAB -tBxQb3N0Z3JlU1FMIERlYmlhbiBSZXBvc2l0b3J5iQJOBBMBCAA4AhsDBQsJCAcD -BRUKCQgLBRYCAwEAAh4BAheAFiEEuXsK/KoaR/BE8kSgf8x9RqzMTPgFAlhtCD8A -CgkQf8x9RqzMTPgECxAAk8uL+dwveTv6eH21tIHcltt8U3Ofajdo+D/ayO53LiYO -xi27kdHD0zvFMUWXLGxQtWyeqqDRvDagfWglHucIcaLxoxNwL8+e+9hVFIEskQAY -kVToBCKMXTQDLarz8/J030Pmcv3ihbwB+jhnykMuyyNmht4kq0CNgnlcMCdVz0d3 -z/09puryIHJrD+A8y3TD4RM74snQuwc9u5bsckvRtRJKbP3GX5JaFZAqUyZNRJRJ -Tn2OQRBhCpxhlZ2afkAPFIq2aVnEt/Ie6tmeRCzsW3lOxEH2K7MQSfSu/kRz7ELf -Cz3NJHj7rMzC+76Rhsas60t9CjmvMuGONEpctijDWONLCuch3Pdj6XpC+MVxpgBy -2VUdkunb48YhXNW0jgFGM/BFRj+dMQOUbY8PjJjsmVV0joDruWATQG/M4C7O8iU0 -B7o6yVv4m8LDEN9CiR6r7H17m4xZseT3f+0QpMe7iQjz6XxTUFRQxXqzmNnloA1T -7VjwPqIIzkj/u0V8nICG/ktLzp1OsCFatWXh7LbU+hwYl6gsFH/mFDqVxJ3+DKQi -vyf1NatzEwl62foVjGUSpvh3ymtmtUQ4JUkNDsXiRBWczaiGSuzD9Qi0ONdkAX3b -ewqmN4TfE+XIpCPxxHXwGq9Rv1IFjOdCX0iG436GHyTLC1tTUIKF5xV4Y0+cXIOI -RgQQEQgABgUCTpdI7gAKCRDFr3dKWFELWqaPAKD1TtT5c3sZz92Fj97KYmqbNQZP -+ACfSC6+hfvlj4GxmUjp1aepoVTo3weJAhwEEAEIAAYFAk6XSQsACgkQTFprqxLS -p64F8Q//cCcutwrH50UoRFejg0EIZav6LUKejC6kpLeubbEtuaIH3r2zMblPGc4i -+eMQKo/PqyQrceRXeNNlqO6/exHozYi2meudxa6IudhwJIOn1MQykJbNMSC2sGUp -1W5M1N5EYgt4hy+qhlfnD66LR4G+9t5FscTJSy84SdiOuqgCOpQmPkVRm1HX5X1+ -dmnzMOCk5LHHQuiacV0qeGO7JcBCVEIDr+uhU1H2u5GPFNHm5u15n25tOxVivb94 -xg6NDjouECBH7cCVuW79YcExH/0X3/9G45rjdHlKPH1OIUJiiX47OTxdG3dAbB4Q -fnViRJhjehFscFvYWSqXo3pgWqUsEvv9qJac2ZEMSz9x2mj0ekWxuM6/hGWxJdB+ -+985rIelPmc7VRAXOjIxWknrXnPCZAMlPlDLu6+vZ5BhFX0Be3y38f7GNCxFkJzl -hWZ4Cj3WojMj+0DaC1eKTj3rJ7OJlt9S9xnO7OOPEUTGyzgNIDAyCiu8F4huLPaT -ape6RupxOMHZeoCVlqx3ouWctelB2oNXcxxiQ/8y+21aHfD4n/CiIFwDvIQjl7dg -mT3u5Lr6yxuosR3QJx1P6rP5ZrDTP9khT30t+HZCbvs5Pq+v/9m6XDmi+NlU7Zuh -Ehy97tL3uBDgoL4b/5BpFL5U9nruPlQzGq1P9jj40dxAaDAX/WKJAj0EEwEIACcC -GwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AFAlB5KywFCQPDFt8ACgkQf8x9RqzM -TPhuCQ//QAjRSAOCQ02qmUAikT+mTB6baOAakkYq6uHbEO7qPZkv4E/M+HPIJ4wd -nBNeSQjfvdNcZBA/x0hr5EMcBneKKPDj4hJ0panOIRQmNSTThQw9OU351gm3YQct -AMPRUu1fTJAL/AuZUQf9ESmhyVtWNlH/56HBfYjE4iVeaRkkNLJyX3vkWdJSMwC/ -LO3Lw/0M3R8itDsm74F8w4xOdSQ52nSRFRh7PunFtREl+QzQ3EA/WB4AIj3VohIG -kWDfPFCzV3cyZQiEnjAe9gG5pHsXHUWQsDFZ12t784JgkGyO5wT26pzTiuApWM3k -/9V+o3HJSgH5hn7wuTi3TelEFwP1fNzI5iUUtZdtxbFOfWMnZAypEhaLmXNkg4zD -kH44r0ss9fR0DAgUav1a25UnbOn4PgIEQy2fgHKHwRpCy20d6oCSlmgyWsR40EPP -YvtGq49A2aK6ibXmdvvFT+Ts8Z+q2SkFpoYFX20mR2nsF0fbt1lfH65P64dukxeR -GteWIeNakDD40bAAOH8+OaoTGVBJ2ACJfLVNM53PEoftavAwUYMrR910qvwYfd/4 -6rh46g1Frr9SFMKYE9uvIJIgDsQB3QBp71houU4H55M5GD8XURYs+bfiQpJG1p7e -B8e5jZx1SagNWc4XwL2FzQ9svrkbg1Y+359buUiP7T6QXX2zY++JAj0EEwEIACcC -GwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AFAlEqbZUFCQg2wEEACgkQf8x9RqzM -TPhFMQ//WxAfKMdpSIA9oIC/yPD/dJpY/+DyouOljpE6MucMy/ArBECjFTBwi/j9 -NYM4ynAk34IkhuNexc1i9/05f5RM6+riLCLgAOsADDbHD4miZzoSxiVr6GQ3YXMb -OGld9kV9Sy6mGNjcUov7iFcf5Hy5w3AjPfKuR9zXswyfzIU1YXObiiZT38l55pp/ -BSgvGVQsvbNjsff5CbEKXS7q3xW+WzN0QWF6YsfNVhFjRGj8hKtHvwKcA02wwjLe -LXVTm6915ZUKhZXUFc0vM4Pj4EgNswH8Ojw9AJaKWJIZmLyW+aP+wpu6YwVCicxB -Y59CzBO2pPJDfKFQzUtrErk9irXeuCCLesDyirxJhv8o0JAvmnMAKOLhNFUrSQ2m -+3EnF7zhfz70gHW+EG8X8mL/EN3/dUM09j6TVrjtw43RLxBzwMDeariFF9yC+5bL -tnGgxjsB9Ik6GV5v34/NEEGf1qBiAzFmDVFRZlrNDkq6gmpvGnA5hUWNr+y0i01L -jGyaLSWHYjgw2UEQOqcUtTFK9MNzbZze4mVaHMEz9/aMfX25R6qbiNqCChveIm8m -Yr5Ds2zdZx+G5bAKdzX7nx2IUAxFQJEE94VLSp3npAaTWv3sHr7dR8tSyUJ9poDw -gw4W9BIcnAM7zvFYbLF5FNggg/26njHCCN70sHt8zGxKQINMc6SJAj0EEwEIACcC -GwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AFAlLpFRkFCQ6EJy0ACgkQf8x9RqzM -TPjOZA//Zp0e25pcvle7cLc0YuFr9pBv2JIkLzPm83nkcwKmxaWayUIG4Sv6pH6h -m8+S/CHQij/yFCX+o3ngMw2J9HBUvafZ4bnbI0RGJ70GsAwraQ0VlkIfg7GUw3Tz -voGYO42rZTru9S0K/6nFP6D1HUu+U+AsJONLeb6oypQgInfXQExPZyliUnHdipei -4WR1YFW6sjSkZT/5C3J1wkAvPl5lvOVthI9Zs6bZlJLZwusKxU0UM4Btgu1Sf3nn -JcHmzisixwS9PMHE+AgPWIGSec/N27a0KmTTvImV6K6nEjXJey0K2+EYJuIBsYUN -orOGBwDFIhfRk9qGlpgt0KRyguV+AP5qvgry95IrYtrOuE7307SidEbSnvO5ezNe -mE7gT9Z1tM7IMPfmoKph4BfpNoH7aXiQh1Wo+ChdP92hZUtQrY2Nm13cmkxYjQ4Z -gMWfYMC+DA/GooSgZM5i6hYqyyfAuUD9kwRN6BqTbuAUAp+hCWYeN4D88sLYpFh3 -paDYNKJ+Gf7Yyi6gThcV956RUFDH3ys5Dk0vDL9NiWwdebWfRFbzoRM3dyGP889a -OyLzS3mh6nHzZrNGhW73kslSQek8tjKrB+56hXOnb4HaElTZGDvD5wmrrhN94kby -Gtz3cydIohvNO9d90+29h0eGEDYti7j7maHkBKUAwlcPvMg5m3Y= -=DA1T ------END PGP PUBLIC KEY BLOCK----- diff --git a/docker/airflow-base/scripts/docker/keys/python-3.10.asc b/docker/airflow-base/scripts/docker/keys/python-3.10.asc deleted file mode 100644 index e69de29b..00000000 diff --git a/docker/compose.airflow.yaml b/docker/compose.airflow.yaml index 511e7814..5dece8ee 100644 --- a/docker/compose.airflow.yaml +++ b/docker/compose.airflow.yaml @@ -23,12 +23,10 @@ # This configuration supports basic configuration using environment variables or an .env file # The following variables are supported: # -# AIRFLOW_VERSION - Apache Airflow version, used to tag the locally built -# Debian Trixie base image (see `make build-airflow-base`). +# AIRFLOW_VERSION - Apache Airflow version installed by Dockerfile.airflow, +# which also selects the image the entrypoint is taken from. +# Keep in sync with apps/elt/pyproject.toml. # Default: 3.2.2 -# AIRFLOW_BASE_IMAGE - Trixie based Airflow base image used by Dockerfile.airflow. -# Build it first with `make build-airflow-base`. -# Default: datafeeder-airflow-base:3.2.2-trixie # AIRFLOW_UID - User ID in Airflow containers # Default: 50000 # AIRFLOW_PROJ_DIR - Base path to which all the files will be volumed. @@ -56,7 +54,6 @@ x-airflow-common: target: development args: AIRFLOW_VERSION: ${AIRFLOW_VERSION:-3.2.2} - AIRFLOW_BASE_IMAGE: ${AIRFLOW_BASE_IMAGE:-datafeeder-airflow-base:3.2.2-trixie} env_file: - ../.env environment: diff --git a/docs/technical_guides/configuration/elt.en.md b/docs/technical_guides/configuration/elt.en.md index e0c2b472..3c0325de 100644 --- a/docs/technical_guides/configuration/elt.en.md +++ b/docs/technical_guides/configuration/elt.en.md @@ -10,13 +10,18 @@ The Docker Compose setup builds a custom Airflow image (`docker/Dockerfile.airfl and the shared `libs/data_manipulation` package. For a platform deployment, deploy this image (or your own image built the same way) as your Airflow workers/scheduler. +The image is built on the official GDAL image rather than on `apache/airflow`, because the ingestion code shells out +to `ogr2ogr` and needs GDAL >= 3.13, which no Debian release packages. Airflow is installed on top from +`apps/elt/uv.lock`, and the official image's `/entrypoint` is reused. See the header of `docker/Dockerfile.airflow` +for the details. + ## Key settings | Setting | Purpose | |---|---| | `AIRFLOW_UID` | User ID Airflow containers run as. Set in `.env`; `make install-python` writes your current UID automatically | | `AIRFLOW_STAGING_TIMEOUT_SECONDS` | Timeout, in seconds, for the staging task execution (default: `600`) | -| `AIRFLOW_VERSION` | Base `apache/airflow` image tag used by `Dockerfile.airflow` (default: `3.1.8`) | +| `AIRFLOW_VERSION` | Airflow version installed by `Dockerfile.airflow`; keep in sync with `apps/elt/pyproject.toml` (default: `3.2.2`) | The backend also needs to be pointed at the Airflow instance: see `AIRFLOW_INTERNAL_URL`, `AIRFLOW_USERNAME` and `AIRFLOW_PASSWORD` in the [backend configuration](backend.md). From 3508ec4597796a16a0f671763ea37302e138ac76 Mon Sep 17 00:00:00 2001 From: Antoine Abt Date: Mon, 7 Sep 2026 08:56:52 +0200 Subject: [PATCH 24/24] fix(elt): correct two pre-existing defects in the DAG params and layer bbox Neither is related to the image rework; both were found while testing it. staging_dag and process_dag declared `default=""` on the callback URL params alongside `minLength=1`, so the default itself failed validation and any trigger that did not override both params was rejected: ValueError: Invalid input for param success_callback_url: '' should be non-empty Use None, which the params' own `["null", "string"]` type already allows and which callback.py already treats as "no callback" via its truthiness check. create_layer built the non-geographic extent by hand and forwarded the caller's bbox as-is, tagging latLonBoundingBox as EPSG:4326 while its coordinates were still in `epsg`: only the is_geographic branch ran the reprojection. Derive both extents through the same helpers in both cases, and hoist the placeholder into a module constant so it stops being a mutable default argument. --- apps/elt/dags/process_dag.py | 4 +-- apps/elt/dags/staging_dag.py | 8 +++--- .../src/data_manipulation/geoserver.py | 27 ++++++++++--------- .../data_manipulation/tests/test_geoserver.py | 17 +++++++----- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/apps/elt/dags/process_dag.py b/apps/elt/dags/process_dag.py index 1af6f3a1..d1956b40 100644 --- a/apps/elt/dags/process_dag.py +++ b/apps/elt/dags/process_dag.py @@ -52,13 +52,13 @@ description="JSON configuration for transformations (optional)", ), "success_callback_url": Param( - default="", + default=None, type=["null", "string"], description="URL to call on success (optional)", minLength=1, ), "failure_callback_url": Param( - default="", + default=None, type=["null", "string"], description="URL to call on failure (optional)", minLength=1, diff --git a/apps/elt/dags/staging_dag.py b/apps/elt/dags/staging_dag.py index 2e80a01a..29f852e6 100644 --- a/apps/elt/dags/staging_dag.py +++ b/apps/elt/dags/staging_dag.py @@ -37,15 +37,15 @@ minLength=1, ), "success_callback_url": Param( - default="", + default=None, type=["null", "string"], - description="URL to call on success", + description="URL to call on success (optional)", minLength=1, ), "failure_callback_url": Param( - default="", + default=None, type=["null", "string"], - description="URL to call on failure", + description="URL to call on failure (optional)", minLength=1, ), "encrypted_credentials": Param( diff --git a/libs/data_manipulation/src/data_manipulation/geoserver.py b/libs/data_manipulation/src/data_manipulation/geoserver.py index 69e796f4..e1a79f33 100644 --- a/libs/data_manipulation/src/data_manipulation/geoserver.py +++ b/libs/data_manipulation/src/data_manipulation/geoserver.py @@ -10,6 +10,10 @@ from data_manipulation.utils import sanitize_name +# Stand-in extent for layers whose real one is unknown, notably non-geographic +# ones. GeoServer requires an extent, so it has to be something. +_PLACEHOLDER_BBOX: dict[str, float] = {"minx": -1.0, "miny": -1.0, "maxx": 0.0, "maxy": 0.0} + class WorkspaceCreationResult(BaseModel): # type: ignore[misc] """Result of workspace creation.""" @@ -92,7 +96,7 @@ def create_layer( abstract: str | None = None, epsg: int = 4326, is_geographic: bool = True, - bbox: dict[str, float] = {"minx": -1.0, "miny": -1.0, "maxx": 0.0, "maxy": 0.0}, + bbox: dict[str, float] | None = None, metadata_links: list[MetadataLink] | None = None, ) -> None: """ @@ -107,7 +111,8 @@ def create_layer( abstract: Layer description/abstract (defaults to table_name if None) epsg: EPSG code for the coordinate reference system (defaults to 4326) is_geographic: Whether the data has valid geometry (defaults to True) - If False, fake bounds will be set + If False, *bbox* is ignored and a placeholder extent is sent + bbox: Extent of the data in *epsg*. Ignored when is_geographic is False. Raises: Exception: If the table doesn't exist in the database or GeoServer fails to create the layer @@ -127,17 +132,13 @@ def create_layer( abstract = table_name try: - native_bounding_box = { - **bbox, - "crs": {"$": f"EPSG:{epsg}", "@class": "projected"}, - } - lat_lon_bounding_box = { - **bbox, - "crs": "EPSG:4326", - } - if is_geographic: - native_bounding_box = _get_native_bbox_from_bbox_string(bbox, epsg) - lat_lon_bounding_box = _get_ll_bbox_from_native_bbox(bbox, epsg) + # Derive both extents the same way whether or not the layer is geographic. + # The non-geographic branch used to forward *bbox* as-is, which labelled the + # latLon extent EPSG:4326 while its coordinates were still in `epsg`, since + # only the geographic branch went through _get_ll_bbox_from_native_bbox. + effective_bbox = bbox if is_geographic and bbox else _PLACEHOLDER_BBOX + native_bounding_box = _get_native_bbox_from_bbox_string(effective_bbox, epsg) + lat_lon_bounding_box = _get_ll_bbox_from_native_bbox(effective_bbox, epsg) feature_type = FeatureType( name=table_name, diff --git a/libs/data_manipulation/tests/test_geoserver.py b/libs/data_manipulation/tests/test_geoserver.py index cebedc27..c51df930 100644 --- a/libs/data_manipulation/tests/test_geoserver.py +++ b/libs/data_manipulation/tests/test_geoserver.py @@ -238,8 +238,10 @@ def test_create_layer_propagates_real_error(self, mock_geoserver: MagicMock) -> assert "Connection timeout" in str(exc_info.value) assert "nonexistent_table" in str(exc_info.value) - def test_create_layer_non_geographic_success(self, mock_geoserver: MagicMock) -> None: - """Test successful layer creation for non-geographic data with fake bounds.""" + def test_create_layer_non_geographic_reprojects_placeholder( + self, mock_geoserver: MagicMock + ) -> None: + """Test that a non-geographic layer's latLon extent is reprojected, not mislabelled.""" epsg = 2154 with patch("data_manipulation.geoserver.RestService") as rest_service_class: @@ -264,15 +266,16 @@ def test_create_layer_non_geographic_success(self, mock_geoserver: MagicMock) -> assert payload["name"] == "test_table" assert payload["srs"] == f"EPSG:{epsg}" - # is_geographic=False skips bbox derivation, so the default placeholder bounds - # are sent as-is and GeoServer treats the layer as having no valid extent. + # The native extent stays the placeholder, expressed in epsg... native_bbox = payload["nativeBoundingBox"] assert (native_bbox["minx"], native_bbox["miny"]) == (-1.0, -1.0) assert (native_bbox["maxx"], native_bbox["maxy"]) == (0.0, 0.0) assert native_bbox["crs"]["$"] == f"EPSG:{epsg}" - assert native_bbox["crs"]["@class"] == "projected" + # ...while the latLon one is actually converted to EPSG:4326 instead of + # reusing the epsg coordinates under a 4326 label. latlon_bbox = payload["latLonBoundingBox"] - assert (latlon_bbox["minx"], latlon_bbox["miny"]) == (-1.0, -1.0) - assert (latlon_bbox["maxx"], latlon_bbox["maxy"]) == (0.0, 0.0) assert latlon_bbox["crs"] == "EPSG:4326" + assert (latlon_bbox["minx"], latlon_bbox["miny"]) != (-1.0, -1.0) + assert -180 <= latlon_bbox["minx"] <= 180 + assert -90 <= latlon_bbox["miny"] <= 90