diff --git a/.dockerignore b/.dockerignore index b55fb8d..1d17dae 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1 @@ .venv -./extracted_files diff --git a/.env.sample b/.env.sample deleted file mode 100644 index 0c63a96..0000000 --- a/.env.sample +++ /dev/null @@ -1,2 +0,0 @@ -SERVICE_PORT= -GPKG_DIR_BASE_PATH= diff --git a/.gitignore b/.gitignore index 4b9313f..b4b13a4 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,4 @@ cython_debug/ .pypirc # Exclude custom dirs -extracted_files/ +geodata/ diff --git a/README.md b/README.md index e6de0ae..9604f2a 100644 --- a/README.md +++ b/README.md @@ -3,46 +3,23 @@ This server is a FastAPI implementation of the Geocoder as a service. It exposes couple of endpoints which return the Geojson geometry. This server loads a file from the following source url: -> Source URL: https://files.emdat.be/data/gaul_gpkg_and_license.zip +## Getting started -## Endpoints +```bash +# Prepare data for the geocoder +cd geodata-prep +docker compose build +docker compose up -Following are the endpoints exposed: +# Run geocoder +cd .. +docker compose build +docker compose up +``` -- GET /by_admin_units?admin_units=location_name +## API documentation -This GET request takes in a query parameter `admin_units` and returns the Geojson geometry polygon if available else an empty dict. - -- GET /by_country_name?country_name=country_name - -This GET request takes in a query parameter `country_name` and returns the Geojson geometry polygon if available else an empty dict. - - -## Scheduled job - -Apart from the endpoint implementation, this server also runs a scheduled task that pulls the Zip file from the above source in a monthly (first day) basis. -This ensures we are using a latest file for this service. - -## Setting up the environment - -There are two environment variables that needs to be setup before the deployment. Check the file `.env.sample` for reference. -* **SERVICE_PORT**: The port on which this server runs -* **GPKG_DIR_BASE_PATH**: The path of the directory where the download file(above) resides. - - -## Local Deployment - -### Build the image - -$ docker compose build - -### Run the container - -$ docker compose up -d - -### To view the logs - -$ docker compose logs -f geocoding +The documentaiton is available at `/docs` ## Production Deployment diff --git a/config.py b/config.py new file mode 100644 index 0000000..7442139 --- /dev/null +++ b/config.py @@ -0,0 +1,20 @@ +import logging + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # World Administrative Boundaries + WAB_FILE_PATH: str = "./geodata-prep/geodata/wab.fgb" + WAB_DOWNLOAD_URL: str = "https://github.com/IFRCGo/geocoding-service/releases/download/v1.0.0/wab.fgb" + # EMDAT GAUL + GAUL_FILE_PATH: str = "./geodata-prep/geodata/gaul.gpkg" + GAUL_DOWNLOAD_URL: str = "https://github.com/IFRCGo/geocoding-service/releases/download/v1.0.0/gaul.gpkg" + + +settings = Settings() + + +# Setup logging +# FIXME: Use hook to setup logging +logging.basicConfig(level=logging.INFO) diff --git a/docker-compose.yml b/docker-compose.yml index 17c1d76..b4d0c31 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,13 +4,11 @@ services: geocoding: build: . ports: - - "${SERVICE_PORT}:${SERVICE_PORT}" + - "${SERVICE_PORT}:8001" volumes: - .:/code - - data:${GPKG_DIR_BASE_PATH} + - ./geodata-prep/geodata:/geodata environment: - GPKG_DIR_BASE_PATH: ${GPKG_DIR_BASE_PATH} - command: /bin/sh -c "uvicorn service:app --host 0.0.0.0 --port ${SERVICE_PORT} --workers 1" - -volumes: - data: + WAB_FILE_PATH: /geodata/simple.wab.fgb + GAUL_FILE_PATH: /geodata/simple.gaul.gpkg + command: /bin/sh -c "uvicorn service:app --host 0.0.0.0 --port 8001 --reload --workers 1" diff --git a/download.py b/download.py deleted file mode 100644 index 21ec001..0000000 --- a/download.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging -import os -import shutil -import zipfile -from typing import Optional - -import requests - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) -logger.setLevel(logging.INFO) - -URL = "https://files.emdat.be/data/gaul_gpkg_and_license.zip" - -GPKG_DIR_BASE_PATH = os.getenv("GPKG_DIR_BASE_PATH") - - -def check_and_download_gaul_file( - filename: str = "gaul_gpkg.zip", - chunk_size: int = 128, - timeout: int = 30, - scheduler_trigger: bool = False, -) -> Optional[str]: - """ - Checks and Downloads the gaul file - if it doesn't exist and returns the file path - """ - zip_file_path = f"/tmp/{filename}" - gaul_file_path = f"{GPKG_DIR_BASE_PATH}/gaul2014_2015.gpkg" - - if scheduler_trigger: - try: - shutil.rmtree(GPKG_DIR_BASE_PATH) - except OSError as e: - logger.error(f"Could not delete the directory {GPKG_DIR_BASE_PATH} with error {e}") - - if not os.path.isdir(GPKG_DIR_BASE_PATH): - os.makedirs(GPKG_DIR_BASE_PATH) - - if os.path.exists(gaul_file_path): - logger.info("The file already exists in the path.") - return gaul_file_path - - logging.info("File Download has started.") - try: - response = requests.get(url=URL, stream=True, timeout=timeout) - except (requests.Timeout, requests.ReadTimeout) as e: - raise Exception("Timout occurred while downloading the zip gpkg file. %s", e) - - if response.status_code == 200: - with open(zip_file_path, "wb") as file: - for chunk in response.iter_content(chunk_size=chunk_size): - file.write(chunk) - logger.info("File downloaded successfully.") - logger.info("Extracting zip contents.") - try: - with zipfile.ZipFile(zip_file_path, "r") as zip_ref: - zip_ref.extractall(GPKG_DIR_BASE_PATH) - except zipfile.BadZipFile: - logger.error("Couldn't extract the zip contents.") - return None - if os.path.exists(zip_file_path): - os.remove(zip_file_path) - logger.info("Extraction done successfully.") - return gaul_file_path - - logger.error("Failed to download file. Status code: %s", response.status_code) - return None diff --git a/geocoding.py b/geocoding.py index 4bb568e..3524dc2 100644 --- a/geocoding.py +++ b/geocoding.py @@ -1,486 +1,134 @@ -import json -import zipfile -from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional, Set, Union +import typing -import fiona # type: ignore -from shapely.geometry import mapping, shape # type: ignore -from shapely.ops import unary_union # type: ignore +import fiona +import pydantic +from shapely.geometry import Point, mapping, shape +from shapely.geometry.base import BaseGeometry +from shapely.ops import unary_union -""" -Note that the source of this file is https://github.com/IFRCGo/pystac-monty/blob/main/pystac_monty/geocoding.py (commit: 30aff67) -Make sure to sync it before using it. -""" +WAB_LAYER = "Layer1" -class MontyGeoCoder(ABC): - @abstractmethod - def get_geometry_from_admin_units(self, admin_units: str) -> Optional[Dict[str, Any]]: - pass +class Country(pydantic.BaseModel): + name: str + iso3: str + iso2: str - @abstractmethod - def get_geometry_by_country_name(self, country_name: str) -> Optional[Dict[str, Any]]: - pass - @abstractmethod - def get_iso3_from_geometry(self, geometry: Dict[str, Any]) -> Optional[str]: - pass +class AdminGeometry(pydantic.BaseModel): + bbox: tuple[float, float, float, float] + geometry: dict[str, typing.Any] - @abstractmethod - def get_geometry_from_iso3(self, iso3: str) -> Optional[Dict[str, Any]]: - pass +class FastGeocoder: + _wab_path: str + _gaul_path: str -WORLD_ADMIN_BOUNDARIES_FGB = "world_admin_boundaries.fgb" + # wab + _geom_from_country_name_cache: dict[str, AdminGeometry] = {} + _geom_from_iso3_cache: dict[str, AdminGeometry] = {} + _geom_from_adm_names_cache: dict[str, AdminGeometry] = {} + # gaul + _adm2_to_adm1_mapping: dict[int, int] = {} + _adm1_to_geometry_mapping: dict[int, BaseGeometry] = {} -class WorldAdministrativeBoundariesGeocoder(MontyGeoCoder): - def __init__(self, fgb_path: str, simplify_tolerance: float = 0.01) -> None: - self.fgb_path = fgb_path - self._path = "" - self._layer = "Layer1" - self._simplify_tolerance = simplify_tolerance - self._cache: Dict[str, Union[Dict[str, Any], int, None]] = {} - self._initialize_path() + def __init__(self, wab_path: str, gaul_path: str) -> None: + self._wab_path = wab_path + self._gaul_path = gaul_path + self._init_adm_mapping() - def _initialize_path(self) -> None: - if self._is_zip_file(self.fgb_path): - fgb_name = self._find_fgb_in_zip(self.fgb_path) - if not fgb_name: - raise ValueError("No .fgb file found in ZIP archive") - self._path = f"zip://{self.fgb_path}!/{fgb_name}" - else: - self._path = self.fgb_path - - def _is_zip_file(self, file_path: str) -> bool: - """Check if a file is a ZIP file""" - try: - with zipfile.ZipFile(file_path, "r"): - return True - except zipfile.BadZipFile: - return False - - def _find_fgb_in_zip(self, zip_path: str) -> Optional[str]: - """Find the first .fgb file in a ZIP archive""" - with zipfile.ZipFile(zip_path, "r") as zf: - names: List[str] = zf.namelist() - for name in names: - if name.lower().endswith(".fgb"): - return name - return None - - def get_iso3_from_geometry(self, geometry: Dict[str, Any]) -> Optional[str]: - if not geometry or not self._path: - return None - - try: - point = shape(geometry) - with fiona.open(self._path, layer=self._layer) as src: - for feature in src: - if shape(feature["geometry"]).contains(point): - return feature["properties"]["iso3"] - except Exception as e: - print(f"Error getting ISO3 from geometry: {str(e)}") - return None - - return None - - def get_geometry_from_admin_units(self, admin_units: str) -> Optional[Dict[str, Any]]: - raise NotImplementedError("Method not implemented") - - def get_geometry_by_country_name(self, country_name: str) -> Optional[Dict[str, Any]]: - raise NotImplementedError("Method not implemented") - - def get_geometry_from_iso3(self, iso3: str) -> Optional[Dict[str, Any]]: - if not iso3 or not self._path: - return None - - try: - with fiona.open(self._path, layer=self._layer) as src: - for feature in src: - if feature["properties"]["iso3"] == iso3: - geom = shape(feature["geometry"]).simplify(self._simplify_tolerance, preserve_topology=True) - return {"geometry": mapping(geom), "bbox": list(geom.bounds)} - except Exception as e: - print(f"Error getting geometry from ISO3: {str(e)}") - return None - - return None - - -GAUL2014_2015_GPCK_ZIP = "gaul2014_2015.gpkg" - - -class GAULGeocoder(MontyGeoCoder): - """ - Implementation of MontyGeoCoder using GAUL geopackage for geocoding. - Loads features dynamically as needed. - """ - - def __init__(self, gpkg_path: str, simplify_tolerance: float = 0.01) -> None: - """ - Initialize GAULGeocoder - - Args: - gpkg_path: Path to the GAUL geopackage file or ZIP containing it - simplify_tolerance: Tolerance for polygon simplification using Douglas-Peucker algorithm. - Higher values result in more simplification. Default is 0.01 degrees. - """ - self.gpkg_path = gpkg_path - self._path = "" # Initialize as empty string instead of None - self._layer = "level2" - self._simplify_tolerance = simplify_tolerance - self._cache: Dict[str, Union[Dict[str, Any], int, None]] = {} # Cache for frequently accessed geometries - self._initialize_path() - - def _initialize_path(self) -> None: - """Set up the correct path for fiona to read""" - if self._is_zip_file(self.gpkg_path): - gpkg_name = self._find_gpkg_in_zip(self.gpkg_path) - if not gpkg_name: - raise ValueError("No .gpkg file found in ZIP archive") - self._path = f"zip://{self.gpkg_path}!/{gpkg_name}" - else: - self._path = self.gpkg_path - - def _is_zip_file(self, file_path: str) -> bool: - """Check if a file is a ZIP file""" - try: - with zipfile.ZipFile(file_path, "r"): - return True - except zipfile.BadZipFile: - return False - - def _find_gpkg_in_zip(self, zip_path: str) -> Optional[str]: - """Find the first .gpkg file in a ZIP archive""" - with zipfile.ZipFile(zip_path, "r") as zf: - names: List[str] = zf.namelist() - for name in names: - if name.lower().endswith(".gpkg"): - return name - return None - - def _get_admin1_for_admin2(self, adm2_code: int) -> Optional[int]: - """Get admin1 code for an admin2 code""" - cache_key = f"adm2_{adm2_code}" - cached_value = self._cache.get(cache_key) - if cached_value is not None and isinstance(cached_value, int): - return cached_value - - if not self._path: - return None - - with fiona.open(self._path, layer=self._layer) as src: + def _init_adm_mapping(self): + with fiona.open(self._gaul_path, layer="level2") as src: for feature in src: - if feature["properties"]["ADM2_CODE"] == adm2_code: - adm1_code = int(feature["properties"]["ADM1_CODE"]) - self._cache[cache_key] = adm1_code - return adm1_code - return None - - def _get_admin1_geometry(self, adm1_code: int) -> Optional[Dict[str, Any]]: - """Get geometry for an admin1 code""" - cache_key = f"adm1_geom_{adm1_code}" - cached_value = self._cache.get(cache_key) - if cached_value is not None and isinstance(cached_value, dict): - return cached_value + properties = feature["properties"] + adm1 = properties["ADM1_CODE"] + adm2 = properties["ADM2_CODE"] + self._adm2_to_adm1_mapping[adm2] = adm1 - if not self._path: - return None - - features = [] - with fiona.open(self._path, layer=self._layer) as src: + with fiona.open(self._gaul_path, layer="level1") as src: for feature in src: - if feature["properties"]["ADM1_CODE"] == adm1_code: - features.append(shape(feature["geometry"])) - - if not features: - return None - - # Combine all geometries and simplify - combined = unary_union(features) - simplified = combined.simplify(self._simplify_tolerance, preserve_topology=True) - result = {"geometry": mapping(simplified), "bbox": list(simplified.bounds)} - self._cache[cache_key] = result - return result - - def _get_country_geometry_by_adm0(self, adm0_code: int) -> Optional[Dict[str, Any]]: - """Get geometry for a country by ADM0 code""" - cache_key = f"adm0_geom_{adm0_code}" - cached_value = self._cache.get(cache_key) - if cached_value is not None and isinstance(cached_value, dict): - return cached_value - - if not self._path: - return None - - features = [] - with fiona.open(self._path, layer=self._layer) as src: + properties = feature["properties"] + geometry = feature["geometry"] + adm1 = properties["ADM1_CODE"] + self._adm1_to_geometry_mapping[adm1] = shape(geometry) + + def get_iso3_from_geometry(self, lng: float, lat: float) -> Country | None: + point = Point(lng, lat) + with fiona.open(self._wab_path, layer=WAB_LAYER) as src: for feature in src: - if feature["properties"]["ADM0_CODE"] == adm0_code: - features.append(shape(feature["geometry"])) - - if not features: - return None - - # Combine all geometries and simplify - combined = unary_union(features) - simplified = combined.simplify(self._simplify_tolerance, preserve_topology=True) - result = {"geometry": mapping(simplified), "bbox": list(simplified.bounds)} - self._cache[cache_key] = result - return result - - def _get_name_to_adm0_mapping(self, name: str) -> Optional[int]: - """Get ADM0 code for an country name""" - cache_key = f"country_{name}" - cached_value = self._cache.get(cache_key) - if cached_value is not None and isinstance(cached_value, int): - return cached_value + geometry: dict[str, typing.Any] = feature["geometry"] + properties: dict[str, typing.Any] = feature["properties"] + if shape(geometry).contains(point): + return Country( + name=properties["name"], + iso3=properties["iso3"], + iso2=properties["iso_3166_1_alpha_2_codes"], + ) + return None - if not self._path: - return None + def get_geometry_from_country_name(self, country_name: str) -> AdminGeometry | None: + from_cache = self._geom_from_country_name_cache.get(country_name) + if from_cache: + return from_cache - with fiona.open(self._path, layer=self._layer) as src: - # Check first few records until we find a match + with fiona.open(self._wab_path, layer=WAB_LAYER) as src: for feature in src: - if feature["properties"].get("ADM0_NAME", "").lower() == name.lower(): - adm0_code = int(feature["properties"]["ADM0_CODE"]) - self._cache[cache_key] = adm0_code - return adm0_code + if feature["properties"]["name"].lower().strip() == country_name: + geom = shape(feature["geometry"]) + val = AdminGeometry( + geometry=mapping(geom), + bbox=geom.bounds, + ) + self._geom_from_country_name_cache[country_name] = val + return val return None - def get_geometry_from_admin_units(self, admin_units: str) -> Optional[Dict[str, Any]]: - """ - Get geometry from admin units JSON string - - Args: - admin_units: JSON string containing admin unit information + def get_geometry_from_adm_codes(self, adm1: list[int], adm2: list[int]): + # Get adm1 from adm2 + adm1_set = set(adm1).union([x for item in adm2 if (x := self._adm2_to_adm1_mapping.get(item)) is not None]) - Returns: - Dictionary containing geometry and bbox if found - """ - if not admin_units or not self._path: - return None - - try: - # Parse admin units JSON - admin_list = json.loads(admin_units) if isinstance(admin_units, str) else None - if not admin_list: - return None - - # Collect admin1 codes from both direct references and admin2 mappings - admin1_codes: Set[int] = set() - for entry in admin_list: - if "adm1_code" in entry: - admin1_codes.add(int(entry["adm1_code"])) - elif "adm2_code" in entry: - adm2_code = int(entry["adm2_code"]) - adm1_code = self._get_admin1_for_admin2(adm2_code) - if adm1_code is not None: - admin1_codes.add(adm1_code) - - if not admin1_codes: - return None - - # Get and combine geometries - geoms: List[Any] = [] - for adm1_code in admin1_codes: - geom_data = self._get_admin1_geometry(adm1_code) - if geom_data and isinstance(geom_data, dict): - geoms.append(shape(geom_data["geometry"])) - - if not geoms: - return None - - # Combine geometries and simplify - combined = unary_union(geoms) - simplified = combined.simplify(self._simplify_tolerance, preserve_topology=True) - return {"geometry": mapping(simplified), "bbox": list(simplified.bounds)} - - except Exception as e: - print(f"Error getting geometry from admin units: {str(e)}") - return None - - def get_geometry_by_country_name(self, country_name: str) -> Optional[Dict[str, Any]]: - """ - Get geometry for a country by its name - - Args: - country_name: Country name - - Returns: - Dictionary containing geometry and bbox if found - """ - if not country_name or not self._path: - return None + key = ",".join(map(str, sorted(adm1_set))) - try: - # Get ADM0 code for the country name - adm0_code = self._get_name_to_adm0_mapping(country_name) - if not adm0_code: - return None + from_cache = self._geom_from_adm_names_cache.get(key) + if from_cache: + return from_cache - # Get country geometry - return self._get_country_geometry_by_adm0(adm0_code) + features: list[BaseGeometry] = [] + for adm1_code in adm1_set: + geometry = self._adm1_to_geometry_mapping.get(adm1_code) + if geometry: + features.append(geometry) - except Exception as e: - print(f"Error getting country geometry for {country_name}: {str(e)}") - return None - - def get_iso3_from_geometry(self, geometry: Dict[str, Any]) -> Optional[str]: - raise NotImplementedError("Method not implemented") - - def get_geometry_from_iso3(self, iso3: str) -> Optional[Dict[str, Any]]: - raise NotImplementedError("Method not implemented") - - -class MockGeocoder(MontyGeoCoder): - """ - Mock implementation of MontyGeoCoder for testing purposes. - Returns simplified test geometries without requiring GAUL data. - """ - - def __init__(self) -> None: - """Initialize mock geocoder with test geometries""" - # Test geometries for Spain and its admin units - self._test_geometries: Dict[str, Dict[str, Any]] = { - # Simplified polygon for Spain - "ESP": { - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-9.0, 36.0], # Southwest - [-9.0, 44.0], # Northwest - [3.0, 44.0], # Northeast - [3.0, 36.0], # Southeast - [-9.0, 36.0], # Close polygon - ] - ], - }, - "bbox": [-9.0, 36.0, 3.0, 44.0], - }, - # Test admin unit geometry - "admin1": { - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-2.0, 40.0], - [-2.0, 42.0], - [0.0, 42.0], - [0.0, 40.0], - [-2.0, 40.0], - ] - ], - }, - "bbox": [-2.0, 40.0, 0.0, 42.0], - }, - } - - def get_geometry_from_admin_units(self, admin_units: str) -> Optional[Dict[str, Any]]: - """ - Get mock geometry for admin units. - Returns a simple test polygon for any valid admin unit JSON. - - Args: - admin_units: JSON string containing admin unit information - - Returns: - Dictionary containing geometry and bbox if found - """ - if not admin_units: - return None - - try: - admin_list = json.loads(admin_units) if isinstance(admin_units, str) else None - if not admin_list: - return None - - # Return test geometry for any valid admin unit request - if isinstance(admin_list, list) and len(admin_list) > 0: - return self._test_geometries["admin1"] - return None - - except Exception as e: - print(f"Error getting mock geometry from admin units: {str(e)}") - return None - - def get_geometry_by_country_name(self, country_name: str) -> Optional[Dict[str, Any]]: - """ - Get mock geometry for a country. - Returns a simple test polygon for Spain ("ESP"). - - Args: - country_name: Country name - - Returns: - Dictionary containing geometry and bbox if found - """ - if not country_name: - return None - - try: - # Return test geometry for Spain - if country_name.lower() == "spain": - return self._test_geometries["ESP"] - return None - - except Exception as e: - print(f"Error getting mock country geometry: {str(e)}") + if not features: return None - def get_iso3_from_geometry(self, geometry: Dict[str, Any]) -> Optional[str]: - """ - Get ISO3 code for a geometry. - Returns the ISO3 code of the first test geometry that intersects with the input geometry. - - Args: - geometry: GeoJSON geometry dict - - Returns: - Optional[str]: ISO3 code if geometry intersects with any test geometry, None otherwise - """ - if not geometry: - return None + combined_feature = unary_union(features) + val = AdminGeometry( + geometry=mapping(combined_feature), + bbox=combined_feature.bounds, + ) + self._geom_from_adm_names_cache[key] = val + return val - try: - # Convert input geometry to shapely - input_shape = shape(geometry) + def get_geometry_from_iso3(self, iso3: str) -> AdminGeometry | None: + from_cache = self._geom_from_iso3_cache.get(iso3) + if from_cache: + return from_cache - # Test intersection with all test geometries - for iso3, test_geom in self._test_geometries.items(): - # Skip non-country geometries (like 'admin1') - if len(iso3) != 3: + with fiona.open(self._wab_path, layer=WAB_LAYER) as src: + for feature in src: + properties: dict[str, typing.Any] = feature["properties"] + geometry: dict[str, typing.Any] = feature["geometry"] + iso3_from_feature = properties["iso3"] + if not iso3_from_feature: continue - - test_shape = shape(test_geom["geometry"]) - if input_shape.intersects(test_shape): - return iso3 - - return None - - except Exception as e: - print(f"Error getting mock ISO3 from geometry: {str(e)}") - return None - - def get_geometry_from_iso3(self, iso3: str) -> Optional[Dict[str, Any]]: - """ - Get geometry for an ISO3 code. - Returns the test geometry for the given ISO3 code. - - Args: - iso3: ISO3 code - - Returns: - Optional[Dict[str, Any]]: Geometry and bbox if found, None otherwise - """ - if not iso3: + if iso3_from_feature.lower().strip() == iso3: + geom = shape(geometry) + val = AdminGeometry( + geometry=mapping(geom), + bbox=geom.bounds, + ) + self._geom_from_iso3_cache[iso3_from_feature] = val + return val return None - - try: - return self._test_geometries.get(iso3) - except Exception as e: - print(f"Error getting mock geometry from ISO3: {str(e)}") - - return None diff --git a/geodata-prep/Dockerfile b/geodata-prep/Dockerfile new file mode 100644 index 0000000..f959fa1 --- /dev/null +++ b/geodata-prep/Dockerfile @@ -0,0 +1,19 @@ +# FIXME: We might not need the whole qgis dependencies anymore +FROM qgis/qgis:3.42-noble AS base + +LABEL maintainer="Montandon Dev" +LABEL org.opencontainers.image.source="https://github.com/IFRCGo/geocoding-service/" + +WORKDIR /code + +RUN apt-get update -y \ + && apt-get install -y --no-install-recommends \ + # Build required packages + curl \ + ca-certificates \ + unzip \ + && apt-get remove -y gcc libc-dev libproj-dev \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +COPY . /code/ diff --git a/geodata-prep/docker-compose.yml b/geodata-prep/docker-compose.yml new file mode 100644 index 0000000..7688430 --- /dev/null +++ b/geodata-prep/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3.8' + +services: + data-processor: + build: . + volumes: + - .:/code + - ./geodata:/geodata + environment: + DATA_DIR: /geodata + TOLERANCE: 0.001 + command: /bin/sh /code/prepare.sh diff --git a/geodata-prep/prepare.sh b/geodata-prep/prepare.sh new file mode 100755 index 0000000..490f9fb --- /dev/null +++ b/geodata-prep/prepare.sh @@ -0,0 +1,53 @@ +#! /bin/sh + +set -e +set -x + +echo "Simplification tolerance is $TOLERANCE" +echo "Data directory is at $DATA_DIR" + +# PROCESS EMDAT GAUL + +TMP_DIR="$DATA_DIR/tmp_gaul" +ZIP_NAME="gaul.zip" +ITEM_NAME="gaul2014_2015.gpkg" +FILE_NAME="gaul.gpkg" +SIMPLIFIED_FILE_NAME="simple.$FILE_NAME" + +# Initialize +mkdir -p "$TMP_DIR" + +# Get zipped GAUL file from EMDAT +curl --continue-at - --no-progress-meter --output "$TMP_DIR/$ZIP_NAME" "https://files.emdat.be/data/gaul_gpkg_and_license.zip" + +# Unzip the GAUL file only +unzip -u "$TMP_DIR/$ZIP_NAME" "$ITEM_NAME" -d "$TMP_DIR" + +# Simplify GAUL file while preserving the topology +ogr2ogr "$TMP_DIR/$SIMPLIFIED_FILE_NAME" "$TMP_DIR/$ITEM_NAME" -simplify $TOLERANCE +# QT_QPA_PLATFORM=offscreen qgis_process plugins enable grassprovider +# QT_QPA_PLATFORM=offscreen qgis_process run grass7:v.generalize --input="$TMP_DIR/$ITEM_NAME" --output="$TMP_DIR/$SIMPLIFIED_FILE_NAME" --threshold=0.01 --type=1 --method=0 --error="$TMP_DIR/errors.qgis.log" + +# Cleanup +mv "$TMP_DIR/$SIMPLIFIED_FILE_NAME" "$DATA_DIR" + +# PROCESS WAL + +# FIXME: read this from environment +DATA_DIR=/geodata + +TMP_DIR="$DATA_DIR/tmp_wab" +FILE_NAME="wab.fgb" +SIMPLIFIED_FILE_NAME="simple.$FILE_NAME" + +# Initialize +mkdir -p "$TMP_DIR" + +# Get WAB +curl --no-progress-meter --output "$TMP_DIR/$FILE_NAME" "https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/world-administrative-boundaries/exports/fgb" + +# Simplify GAUL file while preserving the topology +ogr2ogr "$TMP_DIR/$SIMPLIFIED_FILE_NAME" "$TMP_DIR/$FILE_NAME" -simplify $TOLERANCE + +# Cleanup +mv "$TMP_DIR/$SIMPLIFIED_FILE_NAME" "$DATA_DIR" diff --git a/helm/templates/geocoding/deployment.yaml b/helm/templates/geocoding/deployment.yaml index 026cb57..f541e0c 100644 --- a/helm/templates/geocoding/deployment.yaml +++ b/helm/templates/geocoding/deployment.yaml @@ -35,8 +35,10 @@ spec: env: - name: SERVICE_PORT value: {{ .Values.server.containerPort | quote }} - - name: GPKG_DIR_BASE_PATH - value: "/shared-volume" + - name: WAB_FILE_PATH + value: "/shared-volume/simple.wab.fgb" + - name: GAUL_FILE_PATH + value: "/shared-volume/simple.gaul.gpkg" {{- if .Values.server.persistence.enabled }} volumeMounts: - mountPath: "/shared-volume" diff --git a/init.py b/init.py new file mode 100644 index 0000000..653b20c --- /dev/null +++ b/init.py @@ -0,0 +1,81 @@ +import logging +import os +import pathlib +import typing +from contextlib import asynccontextmanager + +import requests +from fastapi import FastAPI + +from config import settings +from geocoding import FastGeocoder + + +class SharedMem(typing.TypedDict): + geocoder: FastGeocoder | None + + +shared_mem: SharedMem = {"geocoder": None} +logger = logging.getLogger(__name__) + + +def _download_file( + *, + url: str, + dest: str, + timeout: int = 60, + chunk_size: int = 128, +): + response = requests.get(url=url, stream=True, timeout=timeout) + response.raise_for_status() + + with open(dest, "wb") as file: + for chunk in response.iter_content(chunk_size=chunk_size): + file.write(chunk) + + +def _download_geodata( + *, + name: str, + file_path: str, + url_path: str, +): + dir_path = pathlib.Path(os.path.dirname(file_path)) + dir_path.mkdir(parents=True, exist_ok=True) + + if not os.path.exists(file_path): + logger.info(f"Downloading resources for {name}.") + _download_file(url=url_path, dest=file_path) + logger.info(f"Download complete for {name} and stored at {file_path}.") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("The service is starting.") + + _download_geodata( + name="WAB", + url_path=settings.WAB_DOWNLOAD_URL, + file_path=settings.WAB_FILE_PATH, + ) + + _download_geodata( + name="GAUL", + url_path=settings.GAUL_DOWNLOAD_URL, + file_path=settings.GAUL_FILE_PATH, + ) + + logger.info("Initializing geocoder") + geocoder = FastGeocoder( + settings.WAB_FILE_PATH, + settings.GAUL_FILE_PATH, + ) + shared_mem["geocoder"] = geocoder + logger.info("Initialization for geocoder complete.") + + yield + + logger.info("The service is shutting down.") + + +__all__ = ["lifespan", "shared_mem"] diff --git a/pyproject.toml b/pyproject.toml index 97ce7e9..4f4eab4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,13 +6,14 @@ authors = [{ name = "Togglecorp Dev" }] requires-python = "~=3.10" license = "MIT" dependencies = [ - "geopandas>=1.0.1,<2", "fiona>=1.10.1,<2", "fastapi>=0.115.8,<0.116", "requests>=2.32.3,<3", "uvicorn>=0.34.0,<0.35", - "geojson>=2.5.0", "APScheduler==3.11.0", + "pydantic-settings>=2.8.1", + "pydantic>=2.10.6", + "shapely>=2.0.7", ] [dependency-groups] @@ -41,3 +42,14 @@ extend-select = ["I", "E", "F", "W"] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["E402"] + + +[tool.pyright] +exclude = [ + "**/__pycache__", + ".venv/", +] +reportMissingImports = true +reportMissingTypeStubs = false +venvPath = "." +venv = ".venv" diff --git a/service.py b/service.py index 87e980e..0737294 100644 --- a/service.py +++ b/service.py @@ -1,72 +1,110 @@ import logging -from contextlib import asynccontextmanager +import time +from typing import Awaitable, Callable -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Query, Request +from starlette.responses import Response -from download import check_and_download_gaul_file -from geocoding import GAULGeocoder +import geocoding +from init import lifespan, shared_mem +app = FastAPI(lifespan=lifespan) logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) -logger.setLevel(logging.INFO) - -scheduler = BackgroundScheduler() - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Life span handler""" - # Run every first day of the month (midnight) - scheduler.add_job(scheduled_task, CronTrigger(day="1", hour="0", minute="0")) - scheduler.start() - yield - - logger.info("The service is shutting down.") - - -app = FastAPI(lifespan=lifespan) +# TODO: +# - Add decorator to cache requests +# - Add decorator to handle HTTPExceptions +# - Support GAUL files (need to size it down) -file_path = check_and_download_gaul_file() -if not file_path: - raise FileNotFoundError("Geocoding source file couldn't be made available.") -geocoder = GAULGeocoder(gpkg_path=file_path) +@app.middleware("http") +async def add_timing_information( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], +) -> Response: + start_time = time.perf_counter() # Start timing + response = await call_next(request) # Process request + end_time = time.perf_counter() # End timing + process_time = end_time - start_time -def scheduled_task(): - """Scheduled Task""" - global file_path, geocoder - file_path = check_and_download_gaul_file(scheduler_trigger=True) - if not file_path: - raise FileNotFoundError("Geocoding source file couldn't be made available.") + # Add timing info to response headers (optional) + response.headers["X-Process-Time"] = str(process_time) - geocoder = GAULGeocoder(gpkg_path=file_path) + return response @app.get("/") async def home(): - """Test url""" - return {"message": "Welcome to geocoding as service"} - - -@app.get("/by_admin_units") -async def get_by_admin_units(admin_units: str): - """Get the geometry based on admin units""" - if not geocoder: - logger.error("Geocoder is not set.") - return {} - result = geocoder.get_geometry_from_admin_units(admin_units) - return result or {} - - -@app.get("/by_country_name") -async def get_by_country_name(country_name: str): - """Get the geometry based on country name""" - if not geocoder: - logger.error("Geocoder is not set.") - return {} - result = geocoder.get_geometry_by_country_name(country_name) - return result or {} + """Health check""" + return {"message": "Hello World!"} + + +@app.get("/country/iso3") +async def get_iso3(lat: float, lng: float) -> geocoding.Country: + """Get the iso3 based on coordinate""" + try: + geocoder = shared_mem["geocoder"] + if not geocoder: + raise Exception("Geocoder is not initialized") + result = geocoder.get_iso3_from_geometry(lat, lng) + if not result: + raise HTTPException(status_code=404, detail="iso3 not found.") + return result + except HTTPException: + raise + except Exception: + logger.error("Encountered an unexpected error.", exc_info=True) + raise HTTPException(status_code=500, detail="Some error occured.") + + +@app.get("/country/geometry") +async def get_country_geometry( + country_name: str | None = None, + iso3: str | None = None, +) -> geocoding.AdminGeometry: + """Get the country geometry based on country name or iso3""" + try: + geocoder = shared_mem["geocoder"] + if not geocoder: + raise Exception("Geocoder is not initialized") + if iso3: + result = geocoder.get_geometry_from_iso3(iso3.lower().strip()) + elif country_name: + result = geocoder.get_geometry_from_country_name(country_name.lower().strip()) + else: + raise HTTPException(status_code=400, detail="Either iso3 or country_name is required.") + + if not result: + raise HTTPException(status_code=404, detail="Geometry not found.") + return result + except HTTPException: + raise + except Exception: + logger.error("Encountered an unexpected error.", exc_info=True) + raise HTTPException(status_code=500, detail="Some error occured.") + + +@app.get("/admin2/geometries") +async def get_admin2_geometries( + admin1_codes: list[int] = Query(default=[]), + admin2_codes: list[int] = Query(default=[]), +) -> geocoding.AdminGeometry: + """Get the admin 2 geometries based on admin 1 codes or admin 2 codes""" + try: + geocoder = shared_mem["geocoder"] + if not geocoder: + raise Exception("Geocoder is not initialized") + if admin1_codes or admin2_codes: + result = geocoder.get_geometry_from_adm_codes(admin1_codes, admin2_codes) + else: + raise HTTPException(status_code=400, detail="Either admin 1 codes or admin 2 codes is required.") + + if not result: + raise HTTPException(status_code=404, detail="Geometry not found.") + return result + except HTTPException: + raise + except Exception: + logger.error("Encountered an unexpected error.", exc_info=True) + raise HTTPException(status_code=500, detail="Some error occured.") diff --git a/uv.lock b/uv.lock index ac3dd49..9d6608e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.10, <4" resolution-markers = [ "python_full_version >= '3.13' and sys_platform == 'win32'", @@ -336,9 +335,10 @@ dependencies = [ { name = "apscheduler" }, { name = "fastapi" }, { name = "fiona" }, - { name = "geojson" }, - { name = "geopandas" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "requests" }, + { name = "shapely" }, { name = "uvicorn" }, ] @@ -355,9 +355,10 @@ requires-dist = [ { name = "apscheduler", specifier = "==3.11.0" }, { name = "fastapi", specifier = ">=0.115.8,<0.116" }, { name = "fiona", specifier = ">=1.10.1,<2" }, - { name = "geojson", specifier = ">=2.5.0" }, - { name = "geopandas", specifier = ">=1.0.1,<2" }, + { name = "pydantic", specifier = ">=2.10.6" }, + { name = "pydantic-settings", specifier = ">=2.8.1" }, { name = "requests", specifier = ">=2.32.3,<3" }, + { name = "shapely", specifier = ">=2.0.7" }, { name = "uvicorn", specifier = ">=0.34.0,<0.35" }, ] @@ -369,32 +370,6 @@ dev = [ { name = "mypy", specifier = ">=0.990,<0.991" }, ] -[[package]] -name = "geojson" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/5a/33e761df75c732fcea94aaf01f993d823138581d10c91133da58bc231e63/geojson-3.2.0.tar.gz", hash = "sha256:b860baba1e8c6f71f8f5f6e3949a694daccf40820fa8f138b3f712bd85804903", size = 24574 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/a7fa2d650602731c90e0a86279841b4586e14228199e8c09165ba4863e29/geojson-3.2.0-py3-none-any.whl", hash = "sha256:69d14156469e13c79479672eafae7b37e2dcd19bdfd77b53f74fa8fe29910b52", size = 15040 }, -] - -[[package]] -name = "geopandas" -version = "1.0.1" -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/39/08/2cf5d85356e45b10b8d066cf4c3ba1e9e3185423c48104eed87e8afd0455/geopandas-1.0.1.tar.gz", hash = "sha256:b8bf70a5534588205b7a56646e2082fb1de9a03599651b3d80c99ea4c2ca08ab", size = 317736 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/64/7d344cfcef5efddf9cf32f59af7f855828e9d74b5f862eddf5bfd9f25323/geopandas-1.0.1-py3-none-any.whl", hash = "sha256:01e147d9420cc374d26f51fc23716ac307f32b49406e4bd8462c07e82ed1d3d6", size = 323587 }, -] - [[package]] name = "h11" version = "0.14.0" @@ -565,63 +540,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/7f/d322a4125405920401450118dbdc52e0384026bd669939484670ce8b2ab9/numpy-2.2.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:783145835458e60fa97afac25d511d00a1eca94d4a8f3ace9fe2043003c678e4", size = 12839607 }, ] -[[package]] -name = "packaging" -version = "24.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, -] - -[[package]] -name = "pandas" -version = "2.2.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/70/c853aec59839bceed032d52010ff5f1b8d87dc3114b762e4ba2727661a3b/pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5", size = 12580827 }, - { url = "https://files.pythonhosted.org/packages/99/f2/c4527768739ffa4469b2b4fff05aa3768a478aed89a2f271a79a40eee984/pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348", size = 11303897 }, - { url = "https://files.pythonhosted.org/packages/ed/12/86c1747ea27989d7a4064f806ce2bae2c6d575b950be087837bdfcabacc9/pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed", size = 66480908 }, - { url = "https://files.pythonhosted.org/packages/44/50/7db2cd5e6373ae796f0ddad3675268c8d59fb6076e66f0c339d61cea886b/pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57", size = 13064210 }, - { url = "https://files.pythonhosted.org/packages/61/61/a89015a6d5536cb0d6c3ba02cebed51a95538cf83472975275e28ebf7d0c/pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42", size = 16754292 }, - { url = "https://files.pythonhosted.org/packages/ce/0d/4cc7b69ce37fac07645a94e1d4b0880b15999494372c1523508511b09e40/pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f", size = 14416379 }, - { url = "https://files.pythonhosted.org/packages/31/9e/6ebb433de864a6cd45716af52a4d7a8c3c9aaf3a98368e61db9e69e69a9c/pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645", size = 11598471 }, - { url = "https://files.pythonhosted.org/packages/a8/44/d9502bf0ed197ba9bf1103c9867d5904ddcaf869e52329787fc54ed70cc8/pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039", size = 12602222 }, - { url = "https://files.pythonhosted.org/packages/52/11/9eac327a38834f162b8250aab32a6781339c69afe7574368fffe46387edf/pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd", size = 11321274 }, - { url = "https://files.pythonhosted.org/packages/45/fb/c4beeb084718598ba19aa9f5abbc8aed8b42f90930da861fcb1acdb54c3a/pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698", size = 15579836 }, - { url = "https://files.pythonhosted.org/packages/cd/5f/4dba1d39bb9c38d574a9a22548c540177f78ea47b32f99c0ff2ec499fac5/pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc", size = 13058505 }, - { url = "https://files.pythonhosted.org/packages/b9/57/708135b90391995361636634df1f1130d03ba456e95bcf576fada459115a/pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3", size = 16744420 }, - { url = "https://files.pythonhosted.org/packages/86/4a/03ed6b7ee323cf30404265c284cee9c65c56a212e0a08d9ee06984ba2240/pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32", size = 14440457 }, - { url = "https://files.pythonhosted.org/packages/ed/8c/87ddf1fcb55d11f9f847e3c69bb1c6f8e46e2f40ab1a2d2abadb2401b007/pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5", size = 11617166 }, - { url = "https://files.pythonhosted.org/packages/17/a3/fb2734118db0af37ea7433f57f722c0a56687e14b14690edff0cdb4b7e58/pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9", size = 12529893 }, - { url = "https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4", size = 11363475 }, - { url = "https://files.pythonhosted.org/packages/c6/2a/4bba3f03f7d07207481fed47f5b35f556c7441acddc368ec43d6643c5777/pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3", size = 15188645 }, - { url = "https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319", size = 12739445 }, - { url = "https://files.pythonhosted.org/packages/20/e8/45a05d9c39d2cea61ab175dbe6a2de1d05b679e8de2011da4ee190d7e748/pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", size = 16359235 }, - { url = "https://files.pythonhosted.org/packages/1d/99/617d07a6a5e429ff90c90da64d428516605a1ec7d7bea494235e1c3882de/pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", size = 14056756 }, - { url = "https://files.pythonhosted.org/packages/29/d4/1244ab8edf173a10fd601f7e13b9566c1b525c4f365d6bee918e68381889/pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", size = 11504248 }, - { url = "https://files.pythonhosted.org/packages/64/22/3b8f4e0ed70644e85cfdcd57454686b9057c6c38d2f74fe4b8bc2527214a/pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", size = 12477643 }, - { url = "https://files.pythonhosted.org/packages/e4/93/b3f5d1838500e22c8d793625da672f3eec046b1a99257666c94446969282/pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", size = 11281573 }, - { url = "https://files.pythonhosted.org/packages/f5/94/6c79b07f0e5aab1dcfa35a75f4817f5c4f677931d4234afcd75f0e6a66ca/pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", size = 15196085 }, - { url = "https://files.pythonhosted.org/packages/e8/31/aa8da88ca0eadbabd0a639788a6da13bb2ff6edbbb9f29aa786450a30a91/pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", size = 12711809 }, - { url = "https://files.pythonhosted.org/packages/ee/7c/c6dbdb0cb2a4344cacfb8de1c5808ca885b2e4dcfde8008266608f9372af/pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", size = 16356316 }, - { url = "https://files.pythonhosted.org/packages/57/b7/8b757e7d92023b832869fa8881a992696a0bfe2e26f72c9ae9f255988d42/pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", size = 14022055 }, - { url = "https://files.pythonhosted.org/packages/3b/bc/4b18e2b8c002572c5a441a64826252ce5da2aa738855747247a971988043/pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", size = 11481175 }, - { url = "https://files.pythonhosted.org/packages/76/a3/a5d88146815e972d40d19247b2c162e88213ef51c7c25993942c39dbf41d/pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", size = 12615650 }, - { url = "https://files.pythonhosted.org/packages/9c/8c/f0fd18f6140ddafc0c24122c8a964e48294acc579d47def376fef12bcb4a/pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", size = 11290177 }, - { url = "https://files.pythonhosted.org/packages/ed/f9/e995754eab9c0f14c6777401f7eece0943840b7a9fc932221c19d1abee9f/pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", size = 14651526 }, - { url = "https://files.pythonhosted.org/packages/25/b0/98d6ae2e1abac4f35230aa756005e8654649d305df9a28b16b9ae4353bff/pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", size = 11871013 }, - { url = "https://files.pythonhosted.org/packages/cc/57/0f72a10f9db6a4628744c8e8f0df4e6e21de01212c7c981d31e50ffc8328/pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", size = 15711620 }, - { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436 }, -] - [[package]] name = "parso" version = "0.8.4" @@ -771,6 +689,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, ] +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, +] + [[package]] name = "pyflakes" version = "2.5.0" @@ -790,104 +721,12 @@ wheels = [ ] [[package]] -name = "pyogrio" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "numpy" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a5/8f/5a784595524a79c269f2b1c880f4fdb152867df700c97005dda51997da02/pyogrio-0.10.0.tar.gz", hash = "sha256:ec051cb568324de878828fae96379b71858933413e185148acb6c162851ab23c", size = 281950 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/ea/cba24d241858a72b58d8fcd0ad2276f9631fd4528b3062157637e43581eb/pyogrio-0.10.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:046eeeae12a03a3ebc3dc5ff5a87664e4f5fc0a4fb1ea5d5c45d547fa941072b", size = 15083657 }, - { url = "https://files.pythonhosted.org/packages/90/f8/a58795a2aee415c612aac8b425681d932b8983330884207fd1915d234d36/pyogrio-0.10.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:44380f4d9245c776f432526e29ce4d29238aea26adad991803c4f453474f51d3", size = 16457115 }, - { url = "https://files.pythonhosted.org/packages/45/86/74c37e3d4d000bdcd91b25929fe4abc5ad6d93d5f5fbc59a4c7d4f0ed982/pyogrio-0.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14fd3b72b4e2dc59e264607b265c742b0c5ec2ea9e748b115f742381b28dd373", size = 23721911 }, - { url = "https://files.pythonhosted.org/packages/a6/07/35e4127a878ecdcbaaf46f0f2d068b385a454b5b0cab44ea901adc5888a0/pyogrio-0.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:1fea7892f4633cab04d13563e47ec2e87dc2b5cd71b9546018d123184528c151", size = 22941003 }, - { url = "https://files.pythonhosted.org/packages/56/8b/67187ae03dce5cd6f5c5a2f41c405e77059f4cf498e0817b69cec094f022/pyogrio-0.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3539596a76eb8a9d166d6f9d3f36731a8c5bd5c43901209d89dc66b9dc00f079", size = 23861913 }, - { url = "https://files.pythonhosted.org/packages/75/ca/b31083da2e6c4b598b6609a98c655977189fe8982c36d98ea4789a938045/pyogrio-0.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:eac90b2501656892c63bc500c12e71f3dbf7d66ddc5a7fb05cd480d25d1b7022", size = 16171065 }, - { url = "https://files.pythonhosted.org/packages/8d/2c/c761e6adeb81bd4029a137b3240e7214a8c9aaf225883356196afd6ef9d8/pyogrio-0.10.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5b1a51431a27a1cb3e4e19558939c1423106e06e7b67d6285f4fba9c2d0a91b9", size = 15083526 }, - { url = "https://files.pythonhosted.org/packages/c3/e5/983aa9ddf2ff784e973d6b2ec3e874065d6655a5329ca26311b0f3b9f92f/pyogrio-0.10.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:216d69cd77b2b4a0c9d7d449bc239f8b77f3d73f4a05d9c738a0745b236902d8", size = 16457867 }, - { url = "https://files.pythonhosted.org/packages/fa/9a/7103eee7aa3b6ec88e072ef18a05c3aae1ed96fe00009a7a5ce139b50f30/pyogrio-0.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2f0b75f0077ce33256aec6278c2a9c3b79bf0637ddf4f93d3ab2609f0501d96", size = 23926332 }, - { url = "https://files.pythonhosted.org/packages/8b/b2/2ca124343aba24b9a5dcd7c1f43da81e652849cfaf3110d3f507a80af0a1/pyogrio-0.10.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0a47f702d29808c557d2ebea8542c23903f021eae44e16838adef2ab4281c71b", size = 23138693 }, - { url = "https://files.pythonhosted.org/packages/ae/15/501aa4823c142232169d54255ab343f28c4ea9e7fa489b8433dcc873a942/pyogrio-0.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:11e6c71d12da6b445e77d0fc0198db1bd35a77e03a0685e45338cbab9ce02add", size = 24062952 }, - { url = "https://files.pythonhosted.org/packages/94/8d/24f21e6a93ca418231aee3bddade7a0766c89c523832f29e08a8860f83e6/pyogrio-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:d0d74e91a9c0ff2f9abe01b556ff663977193b2d6922208406172d0fc833beff", size = 16172573 }, - { url = "https://files.pythonhosted.org/packages/b5/b5/3c5dfd0b50cbce6f3d4e42c0484647feb1809dbe20e225c4c6abd067e69f/pyogrio-0.10.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2d6558b180e020f71ab7aa7f82d592ed3305c9f698d98f6d0a4637ec7a84c4ce", size = 15079211 }, - { url = "https://files.pythonhosted.org/packages/b8/9a/1ba9c707a094976f343bd0177741eaba0e842fa05ecd8ab97192db4f2ec1/pyogrio-0.10.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:a99102037eead8ba491bc57825c1e395ee31c9956d7bff7b4a9e4fdbff3a13c2", size = 16442782 }, - { url = "https://files.pythonhosted.org/packages/5e/bb/b4250746c2c85fea5004cae93e9e25ad01516e9e94e04de780a2e78139da/pyogrio-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a4c373281d7cbf560c5b61f8f3c7442103ad7f1c7ac4ef3a84572ed7a5dd2f6", size = 23899832 }, - { url = "https://files.pythonhosted.org/packages/bd/4c/79e47e40a8e54e79a45133786a0a58209534f580591c933d40c5ed314fe7/pyogrio-0.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:19f18411bdf836d24cdc08b9337eb3ec415e4ac4086ba64516b36b73a2e88622", size = 23081469 }, - { url = "https://files.pythonhosted.org/packages/47/78/2b62c8a340bcb0ea56b9ddf2ef5fd3d1f101dc0e98816b9e6da87c5ac3b7/pyogrio-0.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1abbcdd9876f30bebf1df8a0273f6cdeb29d03259290008275c7fddebe139f20", size = 24024758 }, - { url = "https://files.pythonhosted.org/packages/43/97/34605480f06b0ad9611bf58a174eccc6f3673275f3d519cf763391892881/pyogrio-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2a3e09839590d71ff832aa95c4f23fa00a2c63c3de82c1fbd4fb8d265792acfc", size = 16160294 }, - { url = "https://files.pythonhosted.org/packages/14/4a/4c8e4f5b9edbca46e0f8d6c1c0b56c0d4af0900c29f4bea22d37853c07f3/pyogrio-0.10.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c90478209537a31dcc65664a87a04c094bb0e08efe502908a6682b8cec0259bf", size = 15076879 }, - { url = "https://files.pythonhosted.org/packages/5f/be/7db0644eef9ef3382518399aaf3332827c43018112d2a74f78784fd496ec/pyogrio-0.10.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:fec45e1963b7058e5a1aa98598aed07c0858512c833d6aad2c672c3ec98bbf04", size = 16440405 }, - { url = "https://files.pythonhosted.org/packages/96/77/f199230ba86fe88b1f57e71428c169ed982de68a32d6082cd7c12d0f5d55/pyogrio-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28cb139f8a5d0365ede602230104b407ae52bb6b55173c8d5a35424d28c4a2c5", size = 23871511 }, - { url = "https://files.pythonhosted.org/packages/25/ac/ca483bec408b59c54f7129b0244cc9de21d8461aefe89ece7bd74ad33807/pyogrio-0.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:cea0187fcc2d574e52af8cfab041fa0a7ad71d5ef6b94b49a3f3d2a04534a27e", size = 23048830 }, - { url = "https://files.pythonhosted.org/packages/d7/3e/c35f2d8dad95b24e568c468f09ff60fb61945065465e0ec7868400596566/pyogrio-0.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:7c02b207ea8cf09c501ea3e95d29152781a00d3c32267286bc36fa457c332205", size = 23996873 }, - { url = "https://files.pythonhosted.org/packages/27/5d/0deb16d228362a097ee3258d0a887c9c0add4b9678bb4847b08a241e124d/pyogrio-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:02e54bcfb305af75f829044b0045f74de31b77c2d6546f7aaf96822066147848", size = 16158260 }, -] - -[[package]] -name = "pyproj" -version = "3.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/10/a8480ea27ea4bbe896c168808854d00f2a9b49f95c0319ddcbba693c8a90/pyproj-3.7.1.tar.gz", hash = "sha256:60d72facd7b6b79853f19744779abcd3f804c4e0d4fa8815469db20c9f640a47", size = 226339 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/a3/c4cd4bba5b336075f145fe784fcaf4ef56ffbc979833303303e7a659dda2/pyproj-3.7.1-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:bf09dbeb333c34e9c546364e7df1ff40474f9fddf9e70657ecb0e4f670ff0b0e", size = 6262524 }, - { url = "https://files.pythonhosted.org/packages/40/45/4fdf18f4cc1995f1992771d2a51cf186a9d7a8ec973c9693f8453850c707/pyproj-3.7.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:6575b2e53cc9e3e461ad6f0692a5564b96e7782c28631c7771c668770915e169", size = 4665102 }, - { url = "https://files.pythonhosted.org/packages/0c/d2/360eb127380106cee83569954ae696b88a891c804d7a93abe3fbc15f5976/pyproj-3.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cb516ee35ed57789b46b96080edf4e503fdb62dbb2e3c6581e0d6c83fca014b", size = 9432667 }, - { url = "https://files.pythonhosted.org/packages/76/a5/c6e11b9a99ce146741fb4d184d5c468446c6d6015b183cae82ac822a6cfa/pyproj-3.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e47c4e93b88d99dd118875ee3ca0171932444cdc0b52d493371b5d98d0f30ee", size = 9259185 }, - { url = "https://files.pythonhosted.org/packages/41/56/a3c15c42145797a99363fa0fdb4e9805dccb8b4a76a6d7b2cdf36ebcc2a1/pyproj-3.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3e8d276caeae34fcbe4813855d0d97b9b825bab8d7a8b86d859c24a6213a5a0d", size = 10469103 }, - { url = "https://files.pythonhosted.org/packages/ef/73/c9194c2802fefe2a4fd4230bdd5ab083e7604e93c64d0356fa49c363bad6/pyproj-3.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f173f851ee75e54acdaa053382b6825b400cb2085663a9bb073728a59c60aebb", size = 10401391 }, - { url = "https://files.pythonhosted.org/packages/c5/1d/ce8bb5b9251b04d7c22d63619bb3db3d2397f79000a9ae05b3fd86a5837e/pyproj-3.7.1-cp310-cp310-win32.whl", hash = "sha256:f550281ed6e5ea88fcf04a7c6154e246d5714be495c50c9e8e6b12d3fb63e158", size = 5869997 }, - { url = "https://files.pythonhosted.org/packages/09/6a/ca145467fd2e5b21e3d5b8c2b9645dcfb3b68f08b62417699a1f5689008e/pyproj-3.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:3537668992a709a2e7f068069192138618c00d0ba113572fdd5ee5ffde8222f3", size = 6278581 }, - { url = "https://files.pythonhosted.org/packages/ab/0d/63670fc527e664068b70b7cab599aa38b7420dd009bdc29ea257e7f3dfb3/pyproj-3.7.1-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:a94e26c1a4950cea40116775588a2ca7cf56f1f434ff54ee35a84718f3841a3d", size = 6264315 }, - { url = "https://files.pythonhosted.org/packages/25/9d/cbaf82cfb290d1f1fa42feb9ba9464013bb3891e40c4199f8072112e4589/pyproj-3.7.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:263b54ba5004b6b957d55757d846fc5081bc02980caa0279c4fc95fa0fff6067", size = 4666267 }, - { url = "https://files.pythonhosted.org/packages/79/53/24f9f9b8918c0550f3ff49ad5de4cf3f0688c9f91ff191476db8979146fe/pyproj-3.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6d6a2ccd5607cd15ef990c51e6f2dd27ec0a741e72069c387088bba3aab60fa", size = 9680510 }, - { url = "https://files.pythonhosted.org/packages/3c/ac/12fab74a908d40b63174dc704587febd0729414804bbfd873cabe504ff2d/pyproj-3.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c5dcf24ede53d8abab7d8a77f69ff1936c6a8843ef4fcc574646e4be66e5739", size = 9493619 }, - { url = "https://files.pythonhosted.org/packages/c4/45/26311d6437135da2153a178125db5dfb6abce831ce04d10ec207eabac70a/pyproj-3.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c2e7449840a44ce860d8bea2c6c1c4bc63fa07cba801dcce581d14dcb031a02", size = 10709755 }, - { url = "https://files.pythonhosted.org/packages/99/52/4ecd0986f27d0e6c8ee3a7bc5c63da15acd30ac23034f871325b297e61fd/pyproj-3.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0829865c1d3a3543f918b3919dc601eea572d6091c0dd175e1a054db9c109274", size = 10642970 }, - { url = "https://files.pythonhosted.org/packages/3f/a5/d3bfc018fc92195a000d1d28acc1f3f1df15ff9f09ece68f45a2636c0134/pyproj-3.7.1-cp311-cp311-win32.whl", hash = "sha256:6181960b4b812e82e588407fe5c9c68ada267c3b084db078f248db5d7f45d18a", size = 5868295 }, - { url = "https://files.pythonhosted.org/packages/92/39/ef6f06a5b223dbea308cfcbb7a0f72e7b506aef1850e061b2c73b0818715/pyproj-3.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ad0ff443a785d84e2b380869fdd82e6bfc11eba6057d25b4409a9bbfa867970", size = 6279871 }, - { url = "https://files.pythonhosted.org/packages/e6/c9/876d4345b8d17f37ac59ebd39f8fa52fc6a6a9891a420f72d050edb6b899/pyproj-3.7.1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:2781029d90df7f8d431e29562a3f2d8eafdf233c4010d6fc0381858dc7373217", size = 6264087 }, - { url = "https://files.pythonhosted.org/packages/ff/e6/5f8691f8c90e7f402cc80a6276eb19d2ec1faa150d5ae2dd9c7b0a254da8/pyproj-3.7.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d61bf8ab04c73c1da08eedaf21a103b72fa5b0a9b854762905f65ff8b375d394", size = 4669628 }, - { url = "https://files.pythonhosted.org/packages/42/ec/16475bbb79c1c68845c0a0d9c60c4fb31e61b8a2a20bc18b1a81e81c7f68/pyproj-3.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04abc517a8555d1b05fcee768db3280143fe42ec39fdd926a2feef31631a1f2f", size = 9721415 }, - { url = "https://files.pythonhosted.org/packages/b3/a3/448f05b15e318bd6bea9a32cfaf11e886c4ae61fa3eee6e09ed5c3b74bb2/pyproj-3.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084c0a475688f934d386c2ab3b6ce03398a473cd48adfda70d9ab8f87f2394a0", size = 9556447 }, - { url = "https://files.pythonhosted.org/packages/6a/ae/bd15fe8d8bd914ead6d60bca7f895a4e6f8ef7e3928295134ff9a7dad14c/pyproj-3.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a20727a23b1e49c7dc7fe3c3df8e56a8a7acdade80ac2f5cca29d7ca5564c145", size = 10758317 }, - { url = "https://files.pythonhosted.org/packages/9d/d9/5ccefb8bca925f44256b188a91c31238cae29ab6ee7f53661ecc04616146/pyproj-3.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bf84d766646f1ebd706d883755df4370aaf02b48187cedaa7e4239f16bc8213d", size = 10771259 }, - { url = "https://files.pythonhosted.org/packages/2a/7d/31dedff9c35fa703162f922eeb0baa6c44a3288469a5fd88d209e2892f9e/pyproj-3.7.1-cp312-cp312-win32.whl", hash = "sha256:5f0da2711364d7cb9f115b52289d4a9b61e8bca0da57f44a3a9d6fc9bdeb7274", size = 5859914 }, - { url = "https://files.pythonhosted.org/packages/3e/47/c6ab03d6564a7c937590cff81a2742b5990f096cce7c1a622d325be340ee/pyproj-3.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:aee664a9d806612af30a19dba49e55a7a78ebfec3e9d198f6a6176e1d140ec98", size = 6273196 }, - { url = "https://files.pythonhosted.org/packages/ef/01/984828464c9960036c602753fc0f21f24f0aa9043c18fa3f2f2b66a86340/pyproj-3.7.1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:5f8d02ef4431dee414d1753d13fa82a21a2f61494737b5f642ea668d76164d6d", size = 6253062 }, - { url = "https://files.pythonhosted.org/packages/68/65/6ecdcdc829811a2c160cdfe2f068a009fc572fd4349664f758ccb0853a7c/pyproj-3.7.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0b853ae99bda66cbe24b4ccfe26d70601d84375940a47f553413d9df570065e0", size = 4660548 }, - { url = "https://files.pythonhosted.org/packages/67/da/dda94c4490803679230ba4c17a12f151b307a0d58e8110820405ca2d98db/pyproj-3.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83db380c52087f9e9bdd8a527943b2e7324f275881125e39475c4f9277bdeec4", size = 9662464 }, - { url = "https://files.pythonhosted.org/packages/6f/57/f61b7d22c91ae1d12ee00ac4c0038714e774ebcd851b9133e5f4f930dd40/pyproj-3.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b35ed213892e211a3ce2bea002aa1183e1a2a9b79e51bb3c6b15549a831ae528", size = 9497461 }, - { url = "https://files.pythonhosted.org/packages/b7/f6/932128236f79d2ac7d39fe1a19667fdf7155d9a81d31fb9472a7a497790f/pyproj-3.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a8b15b0463d1303bab113d1a6af2860a0d79013c3a66fcc5475ce26ef717fd4f", size = 10708869 }, - { url = "https://files.pythonhosted.org/packages/1d/0d/07ac7712994454a254c383c0d08aff9916a2851e6512d59da8dc369b1b02/pyproj-3.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:87229e42b75e89f4dad6459200f92988c5998dfb093c7c631fb48524c86cd5dc", size = 10729260 }, - { url = "https://files.pythonhosted.org/packages/b0/d0/9c604bc72c37ba69b867b6df724d6a5af6789e8c375022c952f65b2af558/pyproj-3.7.1-cp313-cp313-win32.whl", hash = "sha256:d666c3a3faaf3b1d7fc4a544059c4eab9d06f84a604b070b7aa2f318e227798e", size = 5855462 }, - { url = "https://files.pythonhosted.org/packages/98/df/68a2b7f5fb6400c64aad82d72bcc4bc531775e62eedff993a77c780defd0/pyproj-3.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:d3caac7473be22b6d6e102dde6c46de73b96bc98334e577dfaee9886f102ea2e", size = 6266573 }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" +name = "python-dotenv" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, -] - -[[package]] -name = "pytz" -version = "2025.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, ] [[package]] @@ -940,15 +779,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/1e/6461e5cfc8e73ae165b8cff6eb26a4d65274fad0e1435137c5ba34fe4e88/shapely-2.0.7-cp313-cp313-win_amd64.whl", hash = "sha256:aaaf5f7e6cc234c1793f2a2760da464b604584fb58c6b6d7d94144fd2692d67e", size = 1442300 }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, -] - [[package]] name = "sniffio" version = "1.3.1"