diff --git a/.github/workflows/ci-cd-mkdocs.yml b/.github/workflows/ci-cd-mkdocs.yml index f928cf5..fca9d4a 100644 --- a/.github/workflows/ci-cd-mkdocs.yml +++ b/.github/workflows/ci-cd-mkdocs.yml @@ -36,7 +36,7 @@ with: path: "docs/site/" publish: - if: success() && github.ref == 'refs/heads/main' + if: success() && startsWith(github.ref, 'refs/tags') name: Publish doc needs: build permissions: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e06ea91..f3420de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,20 +27,20 @@ repos: - id: check-added-large-files args: [--maxkb=500] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.3 + rev: v0.8.6 hooks: - id: ruff args: [--fix] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.13.0 + rev: v1.14.1 hooks: - id: mypy args: [--config-file=pyproject.toml] files: src additional_dependencies: [pydantic~=2.0,types-pytz,types-requests,types-python-dateutil] - repo: https://github.com/gitleaks/gitleaks - rev: v8.21.2 + rev: v8.22.1 hooks: - id: gitleaks - repo: https://github.com/pypa/pip-audit @@ -49,12 +49,12 @@ repos: - id: pip-audit args: [--skip-editable] - repo: https://github.com/compilerla/conventional-pre-commit - rev: v3.6.0 + rev: v4.0.0 hooks: - id: conventional-pre-commit stages: [commit-msg] args: [feat, fix, ci, chore, test, docs] - repo: https://github.com/kynan/nbstripout - rev: 0.7.1 + rev: 0.8.1 hooks: - id: nbstripout diff --git a/README.md b/README.md index eb746ad..fcf73e0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

CI - Coverage + Coverage Versions Python Downloads @@ -120,7 +120,7 @@ vigi.get_vignette() vignette de vigilance -To have more documentation from MeteoFrance in Vigilance Bulletin : +To have more documentation from Meteo-France in Vigilance Bulletin : - [Meteo France Documentation](https://donneespubliques.meteofrance.fr/?fond=produit&id_produit=305&id_rubrique=50) ## Contributing diff --git a/docs/mkdocs.yaml b/docs/mkdocs.yaml index 271e661..3a867ce 100644 --- a/docs/mkdocs.yaml +++ b/docs/mkdocs.yaml @@ -5,23 +5,23 @@ repo_name: MAIF/meteole site_author: OSSbyMAIF Team docs_dir: pages theme: - name: 'material' + name: material logo: assets/img/svg/meteole-fond-clair.svg - favicon: assets/img/svg/meteole-git.svg + favicon: assets/img/png/meteole-git.png palette: # Palette toggle for automatic mode - media: "(prefers-color-scheme)" toggle: icon: material/brightness-auto name: Switch to light mode - primary: white - accent: red + primary: light green + accent: lime # Palette toggle for light mode - media: "(prefers-color-scheme: light)" scheme: default - primary: white - accent: red + primary: light green + accent: lime toggle: icon: material/brightness-7 name: Switch to dark mode @@ -30,13 +30,13 @@ theme: - media: "(prefers-color-scheme: dark)" scheme: slate primary: black - accent: red + accent: lime toggle: icon: material/brightness-4 name: Switch to system preference font: - text: 'Roboto' - code: 'Roboto Mono' + text: Urbanist + code: Source Code Pro language: en features: - content.tabs.link @@ -81,6 +81,4 @@ nav: - User Guide: - How to: how_to.md - Advanced User Guide: - - Coverages: coverage_parameters.md -extra_css: - - assets/css/mkdocs_extra.css \ No newline at end of file + - Coverages: coverage_parameters.md \ No newline at end of file diff --git a/docs/pages/assets/img/png/meteole-git.png b/docs/pages/assets/img/png/meteole-git.png new file mode 100644 index 0000000..25f7bc4 Binary files /dev/null and b/docs/pages/assets/img/png/meteole-git.png differ diff --git a/docs/pages/assets/img/svg/meteole-git.svg b/docs/pages/assets/img/svg/meteole-git.svg deleted file mode 100644 index 552bbfb..0000000 --- a/docs/pages/assets/img/svg/meteole-git.svg +++ /dev/null @@ -1 +0,0 @@ -This attachment was removed because it contains data that could pose a security risk. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 018897d..98577a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "meteole" -version = "0.0.1" +version = "0.1.0b1" requires-python = ">3.8.0" description = "A Python client library for forecast model APIs (e.g., Météo-France)." readme = "README.md" license = {text = "Apache-2.0"} authors = [ {name = "ThomasBouche"}, - {name = "develop-cs"}, {name = "GratienDSX"}, ] diff --git a/src/meteole/__init__.py b/src/meteole/__init__.py index 3d093e8..1d4015f 100644 --- a/src/meteole/__init__.py +++ b/src/meteole/__init__.py @@ -1,4 +1,3 @@ -import logging from importlib.metadata import version from meteole._arome import AromeForecast @@ -8,21 +7,3 @@ __all__ = ["AromeForecast", "ArpegeForecast", "Vigilance"] __version__ = version("meteole") - - -def setup_logger(): - """Setup logger with proper StreamHandler and formatter""" - logger = logging.getLogger(__name__) - logger.setLevel(logging.INFO) - - handler = logging.StreamHandler() - handler.setLevel(logging.INFO) - - # formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') - formatter = logging.Formatter("%(message)s") - handler.setFormatter(formatter) - - return logger.addHandler(handler) - - -logger = setup_logger() diff --git a/src/meteole/_arome.py b/src/meteole/_arome.py index 145bb17..5600c23 100644 --- a/src/meteole/_arome.py +++ b/src/meteole/_arome.py @@ -1,16 +1,10 @@ -"""The interface for the observational data from the meteo-France API. - -See : -- https://portail-api.meteofrance.fr/web/fr/api/arome -""" - from __future__ import annotations import logging from typing import final from meteole.clients import BaseClient, MeteoFranceClient -from meteole.forecast import Forecast +from meteole.forecast import WeatherForecast logger = logging.getLogger(__name__) @@ -50,8 +44,17 @@ @final -class AromeForecast(Forecast): - """Access the AROME numerical forecast data.""" +class AromeForecast(WeatherForecast): + """Access the AROME numerical weather forecast data from Meteo-France API. + + Doc: + - https://portail-api.meteofrance.fr/web/fr/api/arome + + Attributes: + territory: Covered area (e.g., FRANCE, ANTIL, ...). + precision: Precision value of the forecast. + capabilities: DataFrame containing details on all available coverage ids. + """ # Model constants MODEL_NAME: str = "arome" @@ -62,9 +65,14 @@ class AromeForecast(Forecast): DEFAULT_PRECISION: float = 0.01 CLIENT_CLASS: type[BaseClient] = MeteoFranceClient - def _validate_parameters(self): - """Assert the parameters are valid.""" + def _validate_parameters(self) -> None: + """Check the territory and the precision parameters. + + Raise: + ValueError: At least, one parameter is not good. + """ if self.precision not in [0.01, 0.025]: raise ValueError("Parameter `precision` must be in (0.01, 0.025). It is inferred from argument `territory`") + if self.territory not in AVAILABLE_AROME_TERRITORY: raise ValueError(f"Parameter `territory` must be in {AVAILABLE_AROME_TERRITORY}") diff --git a/src/meteole/_arpege.py b/src/meteole/_arpege.py index f676c45..3616203 100644 --- a/src/meteole/_arpege.py +++ b/src/meteole/_arpege.py @@ -1,15 +1,9 @@ -"""The interface for the observational data from the meteo-France API. - -See : -- https://portail-api.meteofrance.fr/web/fr/api/arpege -""" - from __future__ import annotations from typing import Any, final from meteole.clients import BaseClient, MeteoFranceClient -from meteole.forecast import Forecast +from meteole.forecast import WeatherForecast AVAILABLE_ARPEGE_TERRITORY: list[str] = ["EUROPE", "GLOBE", "ATOURX", "EURAT"] @@ -68,8 +62,17 @@ @final -class ArpegeForecast(Forecast): - """Access the ARPEGE numerical forecast data.""" +class ArpegeForecast(WeatherForecast): + """Access the ARPEGE numerical weather forecast data from Meteo-France API. + + Doc: + - https://portail-api.meteofrance.fr/web/fr/api/arpege + + Attributes: + territory: Covered area (e.g., FRANCE, ANTIL, ...). + precision: Precision value of the forecast. + capabilities: DataFrame containing details on all available coverage ids. + """ # Model constants MODEL_NAME: str = "arpege" @@ -87,24 +90,20 @@ def __init__( territory: str = "EUROPE", **kwargs: Any, ): - """ - Initializes an ArpegeForecast object for accessing ARPEGE forecast data. + """Initializes an ArpegeForecast object. The `precision` of the forecast is inferred from the specified `territory`. Args: - territory (str, optional): The ARPEGE territory to fetch. Defaults to "EUROPE". - api_key (str | None, optional): The API key for authentication. Defaults to None. - token (str | None, optional): The API token for authentication. Defaults to None. - application_id (str | None, optional): The Application ID for authentication. Defaults to None. - cache_dir (str | None, optional): Path to the cache directory. Defaults to None. - If not provided, the cache directory is set to "/tmp/cache". + territory: The ARPEGE territory to fetch. Defaults to "EUROPE". + api_key: The API key for authentication. Defaults to None. + token: The API token for authentication. Defaults to None. + application_id: The Application ID for authentication. Defaults to None. Notes: - See `MeteoFranceClient` for additional details on the parameters `api_key`, `token`, and `application_id`. - Available territories are listed in the `AVAILABLE_TERRITORY` constant. - """ super().__init__( client=client, @@ -113,7 +112,11 @@ def __init__( **kwargs, ) - def _validate_parameters(self): - """Assert the parameters are valid.""" + def _validate_parameters(self) -> None: + """Check the territory and the precision parameters. + + Raise: + ValueError: At least, one parameter is not good. + """ if self.territory not in AVAILABLE_ARPEGE_TERRITORY: raise ValueError(f"The parameter precision must be in {AVAILABLE_ARPEGE_TERRITORY}") diff --git a/src/meteole/_vigilance.py b/src/meteole/_vigilance.py index 0216013..6f5b695 100644 --- a/src/meteole/_vigilance.py +++ b/src/meteole/_vigilance.py @@ -17,14 +17,13 @@ @final class Vigilance: - """Wrapper around the meteo-France API for the vigilance data. - Ressources are: + """Easy access to the vigilance data of Meteo-France. + + Resources are: - textesvigilance - cartevigilance - Documentation - ------------- - See: + Docs: - https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesVigilance - https://donneespubliques.meteofrance.fr/client/document/descriptiftechnique_vigilancemetropole_ donneespubliques_v4_20230911_307.pdf @@ -49,7 +48,7 @@ def __init__( client: BaseClient | None = None, **kwargs: Any, ) -> None: - """TODO""" + """Initialize attributes""" if client is not None: self._client = client else: @@ -57,11 +56,10 @@ def __init__( self._client = MeteoFranceClient(**kwargs) def get_bulletin(self) -> dict[str, Any]: - """ - Retrieve the vigilance bulletin. + """Retrieve the vigilance bulletin. Returns: - dict: a Dict representing the vigilance bulletin + Dictionary representing the vigilance bulletin """ path: str = self.VIGILANCE_BASE_PATH + self.API_VERSION + "/textesvigilance/encours" @@ -72,7 +70,7 @@ def get_bulletin(self) -> dict[str, Any]: return resp.json() except MissingDataError as e: - if "no matching blob" in e.message: + if "no matching blob" in str(e): logger.warning("Ongoing vigilance requires no publication") else: logger.error(f"Unexpected error: {e}") @@ -82,11 +80,10 @@ def get_bulletin(self) -> dict[str, Any]: return {} def get_map(self) -> dict[str, Any]: - """ - Get the vigilance map with predicted risk displayed. + """Get the vigilance map with predicted risk displayed. Returns: - dict: a Dict with the predicted risk. + Dictionary with the predicted risk. """ path: str = self.VIGILANCE_BASE_PATH + self.API_VERSION + "/cartevigilance/encours" logger.debug(f"GET {path}") @@ -96,12 +93,12 @@ def get_map(self) -> dict[str, Any]: return resp.json() def get_phenomenon(self) -> tuple[pd.DataFrame, pd.DataFrame]: - """ - Get risk prediction by phenomenon and by domain. + """Get risk prediction by phenomenon and by domain. Returns: - pd.DataFrame: a DataFrame with phenomenon by id - pd.DataFrame: a DataFrame with phenomenon by domain + Tuple of: + A DataFrame with phenomenon by id + A DataFrame with phenomenon by domain """ df_carte = pd.DataFrame(self.get_map()) periods_data = df_carte.loc["periods", "product"] @@ -128,9 +125,7 @@ def get_phenomenon(self) -> tuple[pd.DataFrame, pd.DataFrame]: return df_phenomenon, df_timelaps def get_vignette(self) -> None: - """ - Get png. - """ + """Get png file.""" path: str = self.VIGILANCE_BASE_PATH + self.API_VERSION + "/vignettenationale-J-et-J1/encours" logger.debug(f"GET {path}") diff --git a/src/meteole/clients.py b/src/meteole/clients.py index 698b323..2e51104 100644 --- a/src/meteole/clients.py +++ b/src/meteole/clients.py @@ -20,24 +20,36 @@ class HttpStatus(int, Enum): """Http status codes""" - OK: int = 200 - BAD_REQUEST: int = 400 - UNAUTHORIZED: int = 401 - FORBIDDEN: int = 403 - NOT_FOUND: int = 404 - TOO_MANY_REQUESTS: int = 429 - INTERNAL_ERROR: int = 500 - BAD_GATEWAY: int = 502 - UNAVAILABLE: int = 503 - GATEWAY_TIMEOUT: int = 504 + OK = 200 + BAD_REQUEST = 400 + UNAUTHORIZED = 401 + FORBIDDEN = 403 + NOT_FOUND = 404 + TOO_MANY_REQUESTS = 429 + INTERNAL_ERROR = 500 + BAD_GATEWAY = 502 + UNAVAILABLE = 503 + GATEWAY_TIMEOUT = 504 class BaseClient(ABC): - """TODO""" + """(Abstract) + + Base class for weather forecast provider clients. + """ @abstractmethod def get(self, path: str, *, params: dict[str, Any] | None = None, max_retries: int = 5) -> Response: - """TODO""" + """Retrieve some data with retry capability. + + Args: + path: Path to a resource. + params: The query parameters of the request. + max_retries: The maximum number of retry attempts in case of failure. + + Returns: + The response returned by the API. + """ raise NotImplementedError @@ -46,17 +58,11 @@ class MeteoFranceClient(BaseClient): This class handles the connection setup and token refreshment required for authenticating and making requests to the Meteo France API. - - Attributes: - api_key (str | None): The API key for accessing the Meteo France API. - token (str | None): The authentication token for accessing the API. - application_id (str | None): The application ID used for identification. - verify (Path | None): The path to a file or directory of trusted CA certificates for SSL verification. """ # Class constants API_BASE_URL: str = "https://public-api.meteofrance.fr/public/" - TOKEN_URL: str = "https://portail-api.meteofrance.fr/token" + TOKEN_URL: str = "https://portail-api.meteofrance.fr/token" # noqa: S105 GET_TOKEN_TIMEOUT_SEC: int = 10 INVALID_JWT_ERROR_CODE: str = "900901" RETRY_DELAY_SEC: int = 10 @@ -70,13 +76,13 @@ def __init__( certs_path: Path | None = None, ) -> None: """ - Initializes the MeteoFranceClient object. + Initialize attributes. Args: - api_key (str | None): The API key for accessing the Meteo France API. - token (str | None): The authentication token for accessing the API. - application_id (str | None): The application ID used for identification. - verify (Path | None): The path to a file or directory of trusted CA certificates for SSL verification. + token: The authentication token for accessing the API. + api_key: The API key for accessing the Meteo France API. + application_id: The application ID used for identification. + certs_path: The path to a file or directory of trusted CA certificates for SSL verification. """ self._token = token self._api_key = api_key @@ -92,16 +98,15 @@ def __init__( def get(self, path: str, *, params: dict[str, Any] | None = None, max_retries: int = 5) -> Response: """ - Makes a GET request to the API with optional retries. + Make a GET request to the API with optional retries. Args: - url (str): The URL to send the GET request to. - params (dict, optional): The query parameters to include in the request. Defaults to None. - max_retries (int, optional): The maximum number of retry attempts in case of failure. Defaults to 5. + path: Path to a resource. + params: The query parameters of the request. + max_retries: The maximum number of retry attempts in case of failure. Returns: - requests.Response: The response returned by the API. - + The response returned by the API. """ url: str = self.API_BASE_URL + path attempt: int = 0 @@ -157,8 +162,10 @@ def get(self, path: str, *, params: dict[str, Any] | None = None, max_retries: i raise GenericMeteofranceApiError(f"Failed to get a successful response from API after {attempt} retries") def _connect(self): - """Connect to the MeteoFrance API. + """(Protected) + Connect to the Meteo-France API. + Note: If the API key is provided, it is used to authenticate the user. If the token is provided, it is used to authenticate the user. If the application ID is provided, a token is requested from the API. @@ -183,11 +190,15 @@ def _connect(self): self._session.headers.update({"Authorization": f"Bearer {self._token}"}) def _get_token(self) -> str: - """request a token from the meteo-France API. + """(Protected) + Request a token from the Meteo-France API. The token lasts 1 hour, and is used to authenticate the user. If a new token is requested before the previous one expires, the previous one is invalidated. A local cache is used to avoid requesting a new token at each run of the script. + + Rerturns: + A JWT. """ if self._token_expired is False and self._token is not None: # Use cached token @@ -216,11 +227,13 @@ def _get_token(self) -> str: return token def _is_token_expired(self, response: Response) -> bool: - """Check if the token is expired. + """(Protected) + Check if the token is expired. - Returns - ------- - bool + Args: + response: A request's response from the API. + + Returns: True if the token is expired, False otherwise. """ result: bool = False diff --git a/src/meteole/errors.py b/src/meteole/errors.py index ebb8527..e596eb7 100644 --- a/src/meteole/errors.py +++ b/src/meteole/errors.py @@ -1,53 +1,61 @@ +"""Error implementations""" + from __future__ import annotations +from typing import Any, MutableMapping + import xmltodict class GenericMeteofranceApiError(Exception): - """Exception raised errors in the input parameters where a required field is missing. + """Exception raised when a required field is missing in the input parameters. Args: - message (str): Human-readable string descipting the exceptetion. - description (str): More detailed description of the error.""" + message: Human-readable string describing the exception. + description: More detailed description of the error. + """ - def init(self, text: str) -> None: + def __init__(self, text: str) -> None: """Initialize the exception with an error message parsed from an XML string. Args: - text (str): XML string containing the error details, + text: XML string containing the error details, expected to follow a specific schema with 'am:fault' as the root element and 'am:message' and 'am:description' as child elements.""" + try: + # Parse error message with xmltodict + data: MutableMapping[str, Any] = xmltodict.parse(text) + msg_content: str = data["am:fault"]["am:message"] + description: str = data["am:fault"]["am:description"] + message: str = f"{msg_content}\n {description}" + except Exception: + message = text - # parse the error message with xmltodict - data = xmltodict.parse(text) - message = data["am:fault"]["am:message"] - description = data["am:fault"]["am:description"] - self.message = f"{message}\n {description}" - super().__init__(self.message) + super().__init__(message) class MissingDataError(Exception): """Exception raised errors in the input data is missing""" - def init(self, text: str) -> None: + def __init__(self, text: str) -> None: """Initialize the exception with an error message parsed from an XML string. Args: - text (str): XML string containing the error details, + text: XML string containing the error details, expected to follow a specific schema with 'am:fault' as the root element and 'am:message' and 'am:description' as child elements.""" - # parse the error message with xmltodict try: - data = xmltodict.parse(text) - exception = data["mw:fault"]["mw:description"]["ns0:ExceptionReport"]["ns0:Exception"] - code = exception["@exceptionCode"] - locator = exception["@locator"] - exception_text = exception["ns0:ExceptionText"] - message = f"Error code: {code}\nLocator: {locator}\nText: {exception_text}" + # Parse error message with xmltodict + data: MutableMapping[str, Any] = xmltodict.parse(text) + exception: dict[Any, Any] = data["mw:fault"]["mw:description"]["ns0:ExceptionReport"]["ns0:Exception"] + code: str = exception["@exceptionCode"] + locator: str = exception["@locator"] + exception_text: str = exception["ns0:ExceptionText"] + message: str = f"Error code: {code}\nLocator: {locator}\nText: {exception_text}" except Exception: message = text - self.message = message - super().__init__(self.message) + + super().__init__(message) diff --git a/src/meteole/forecast.py b/src/meteole/forecast.py index 8b66b89..62bec8d 100644 --- a/src/meteole/forecast.py +++ b/src/meteole/forecast.py @@ -19,14 +19,17 @@ logger = logging.getLogger(__name__) -class Forecast(ABC): - """(Abstract class) - Provides a unified interface to query AROME and ARPEGE endpoints - - Attributes - ---------- - capabilities: pandas.DataFrame - coverage dataframe containing the details of all available coverage_ids +class WeatherForecast(ABC): + """(Abstract) + Base class for weather forecast models. + + Note: Currently, this class is highly related to Meteo-France models. + This will not be the case in the future. + + Attributes: + territory: Covered area (e.g., FRANCE, ANTIL, ...). + precision: Precision value of the forecast. + capabilities: DataFrame containing details on all available coverage ids. """ # Class constants @@ -53,7 +56,14 @@ def __init__( precision: float = DEFAULT_PRECISION, **kwargs: Any, ): - """Init the Forecast object.""" + """Initialize attributes. + + Args: + territory: The ARPEGE territory to fetch. + api_key: The API key for authentication. Defaults to None. + token: The API token for authentication. Defaults to None. + application_id: The Application ID for authentication. Defaults to None. + """ self.territory = territory # "FRANCE", "ANTIL", or others (see API doc) self.precision = precision self._validate_parameters() @@ -72,14 +82,26 @@ def __init__( @property def capabilities(self) -> pd.DataFrame: - """TODO""" + """Getter method of the capabilities attribute. + + Returns: + DataFrame of details on all available coverage ids. + """ if self._capabilities is None: self._capabilities = self._build_capabilities() return self._capabilities @property - def indicators(self) -> pd.DataFrame: - """TODO""" + def indicators(self) -> list[str]: + """Getter method of the indicators. + + Indicators are identifying the kind of a predicted value (i.e., measurement). + + Warning: This method is deprecated as INDICATORS is now a class constant. + + Returns: + List of covered indicators. + """ warn( "The 'indicators' attribute is deprecated, it will be removed soon. " "Use 'INDICATORS' instead (class constant).", @@ -89,35 +111,42 @@ def indicators(self) -> pd.DataFrame: return self.INDICATORS @abstractmethod - def _validate_parameters(self): - """Assert parameters are valid.""" - pass + def _validate_parameters(self) -> None: + """Check the territory and the precision parameters. + + Raise: + ValueError: At least, one parameter is not good. + """ + raise NotImplementedError def get_capabilities(self) -> pd.DataFrame: - "Returns the coverage dataframe containing the details of all available coverage_ids" + """Explicit "getter method" of the capabilities attribute. + + Returns: + DataFrame of details on all available coverage ids. + """ return self.capabilities - def get_coverage_description(self, coverage_id: str) -> dict: - """This endpoint returns the available axis (times, heights) to properly query coverage + def get_coverage_description(self, coverage_id: str) -> dict[str, Any]: + """Return the available axis (times, heights) of a coverage. - TODO: other informations can be fetched from this endpoint, not yet implemented. + TODO: Other informations can be fetched, not yet implemented. Args: - coverage_id (str): use :meth:`get_capabilities()` to list all available coverage_id - """ + coverage_id: An id of a coverage, use get_capabilities() to get them. - # Get coverage description + Returns: + A dictionary containing more info on the coverage. + """ description = self._get_coverage_description(coverage_id) grid_axis = description["wcs:CoverageDescriptions"]["wcs:CoverageDescription"]["gml:domainSet"][ "gmlrgrid:ReferenceableGridByVectors" ]["gmlrgrid:generalGridAxis"] return { - "forecast_horizons": [ - int(time / 3600) for time in self.__class__._get_available_feature(grid_axis, "time") - ], - "heights": self.__class__._get_available_feature(grid_axis, "height"), - "pressures": self.__class__._get_available_feature(grid_axis, "pressure"), + "forecast_horizons": [int(time / 3600) for time in self._get_available_feature(grid_axis, "time")], + "heights": self._get_available_feature(grid_axis, "height"), + "pressures": self._get_available_feature(grid_axis, "pressure"), } def get_coverage( @@ -132,24 +161,30 @@ def get_coverage( interval: str | None = None, coverage_id: str = "", ) -> pd.DataFrame: - """Returns the data associated with the coverage_id for the selected parameters. + """Return the coverage data (i.e., the weather forecast data). Args: - coverage_id (str): coverage_id, get the list using :meth:`get_capabilities` - lat (tuple): minimum and maximum latitude - long (tuple): minimum and maximum longitude - heights (list): heights in meters - pressures (list): pressures in hPa - forecast_horizons (list): list of integers, representing the forecast horizon in hours + indicator: Indicator of a coverage to retrieve. + lat: Minimum and maximum latitude. + long: Minimum and maximum longitude. + heights: Heights in meters. + pressures: Pressures in hPa. + forecast_horizons: List of integers, representing the forecast horizons in hours. + run: The model inference timestamp. If None, defaults to the latest available run. + Expected format: "YYYY-MM-DDTHH:MM:SSZ". + interval: The aggregation period. Must be None for instant indicators; + raises an error if specified. Defaults to "P1D" for time-aggregated indicators such + as TOTAL_PRECIPITATION. + coverage_id: An id of a coverage, use get_capabilities() to get them. Returns: pd.DataFrame: The complete run for the specified execution. """ - # ensure we only have one of coverage_id, indicator + # Ensure we only have one of coverage_id, indicator if not bool(indicator) ^ bool(coverage_id): raise ValueError("Argument `indicator` or `coverage_id` need to be set (only one of them)") - if indicator: + if indicator is not None: coverage_id = self._get_coverage_id(indicator, run, interval) logger.info(f"Using `coverage_id={coverage_id}`") @@ -179,7 +214,12 @@ def get_coverage( return pd.concat(df_list, axis=0).reset_index(drop=True) def _build_capabilities(self) -> pd.DataFrame: - "Returns the coverage dataframe containing the details of all available coverage_ids" + """(Protected) + Fetch and build the model capabilities. + + Returns: + DataFrame all the details. + """ logger.info("Fetching all available coverages...") @@ -221,14 +261,14 @@ def _get_coverage_id( run: str | None = None, interval: str | None = None, ) -> str: - """ - Retrieves a `coverage_id` from the capabilities based on the provided parameters. + """(Protected) + Retrieve a `coverage_id` from the capabilities based on the provided parameters. Args: - indicator (str): The indicator to retrieve. This parameter is required. - run (str | None, optional): The model inference timestamp. If None, defaults to the latest available run. + indicator: The indicator to retrieve. This parameter is required. + run: The model inference timestamp. If None, defaults to the latest available run. Expected format: "YYYY-MM-DDTHH:MM:SSZ". Defaults to None. - interval (str | None, optional): The aggregation period. Must be None for instant indicators; + interval: The aggregation period. Must be None for instant indicators; raises an error if specified. Defaults to "P1D" for time-aggregated indicators such as TOTAL_PRECIPITATION. @@ -291,7 +331,8 @@ def _get_coverage_id( def _raise_if_invalid_or_fetch_default( self, param_name: str, inputs: list[int] | None, availables: list[int] ) -> list[int]: - """Checks validity of `inputs`. + """(Protected) + Checks validity of `inputs`. Checks if the elements in `inputs` are in `availables` and raises a ValueError if not. If `inputs` is empty or None, uses the first element from `availables` as the default value. @@ -319,8 +360,12 @@ def _raise_if_invalid_or_fetch_default( logger.info(f"Using `{param_name}={inputs}`") return inputs - def _fetch_capabilities(self) -> dict: - """The Capabilities of the AROME/ARPEGE service.""" + def _fetch_capabilities(self) -> dict[Any, Any]: + """Fetch the model capabilities. + + Returns: + Raw capabilities (dictionary). + """ url = f"{self._model_base_path}/{self._entry_point}/GetCapabilities" params = { @@ -345,10 +390,11 @@ def _fetch_capabilities(self) -> dict: logger.error(f"Response: {xml}") raise e - def _get_coverage_description(self, coverage_id: str) -> dict: - """Get the description of a coverage. + def _get_coverage_description(self, coverage_id: str) -> dict[Any, Any]: + """(Protected) + Get the description of a coverage. - .. warning:: + Warning: The return value is the raw XML data. Not yet parsed to be usable. In the future, it should be possible to use it to @@ -371,7 +417,7 @@ def _get_coverage_description(self, coverage_id: str) -> dict: return xmltodict.parse(response.text) def _grib_bytes_to_df(self, grib_str: bytes) -> pd.DataFrame: - """ + """(Protected) Converts GRIB data (in binary format) into a pandas DataFrame. This method writes the binary GRIB data to a temporary file, reads it using @@ -417,7 +463,8 @@ def _get_data_single_forecast( lat: tuple, long: tuple, ) -> pd.DataFrame: - """Returns the forecasts for a given time and indicator. + """(Protected) + Return the forecast's data for a given time and indicator. Args: coverage_id (str): the indicator. @@ -486,7 +533,7 @@ def _get_coverage_file( lat: tuple = (37.5, 55.4), long: tuple = (-12, 16), ) -> bytes: - """ + """(Protected) Retrieves raster data for a specified model prediction and saves it to a file. If no `filepath` is provided, the file is saved to a default cache directory under @@ -537,14 +584,28 @@ def _get_coverage_file( return response.content @staticmethod - def _get_available_feature(grid_axis, feature_name): - features = [] - feature_grid_axis = [ - ax for ax in grid_axis if ax["gmlrgrid:GeneralGridAxis"]["gmlrgrid:gridAxesSpanned"] == feature_name + def _get_available_feature(grid_axis: list[dict[str, Any]], feature_name: str) -> list[int]: + """(Protected) + Retrieve available features. + + TODO: add more details here. + + Args: + grid_axis: ? + feature_name: ? + + Returns: + List of available feature. + """ + feature_grid_axis: list[dict[str, Any]] = [ + ax for ax in grid_axis if str(ax["gmlrgrid:GeneralGridAxis"]["gmlrgrid:gridAxesSpanned"]) == feature_name ] - if feature_grid_axis: - features = feature_grid_axis[0]["gmlrgrid:GeneralGridAxis"]["gmlrgrid:coefficients"].split(" ") - features = [int(feature) for feature in features] + + features: list[int] = ( + list(map(int, feature_grid_axis[0]["gmlrgrid:GeneralGridAxis"]["gmlrgrid:coefficients"].split(" "))) + if len(feature_grid_axis) > 0 + else [] + ) return features def get_combined_coverage( @@ -570,8 +631,9 @@ def get_combined_coverage( runs (list[str]): A list of runs for each indicator. Format should be "YYYY-MM-DDTHH:MM:SSZ". heights (list[int] | None): A list of heights in meters to filter by (default is None). pressures (list[int] | None): A list of pressures in hPa to filter by (default is None). - intervals (list[str] | None): A list of aggregation periods (default is None). Must be `None` or "" for instant indicators; - otherwise, raises an exception. Defaults to 'P1D' for time-aggregated indicators. + intervals (list[str] | None): A list of aggregation periods (default is None). + Must be `None` or "" for instant indicators ; otherwise, raises an exception. + Defaults to 'P1D' for time-aggregated indicators. lat (tuple): The latitude range as (min_latitude, max_latitude). Defaults to FRANCE_METRO_LATITUDES. long (tuple): The longitude range as (min_longitude, max_longitude). Defaults to FRANCE_METRO_LONGITUDES. forecast_horizons (list[int] | None): A list of forecast horizon values in hours. Defaults to None. @@ -581,9 +643,8 @@ def get_combined_coverage( Raises: ValueError: If the length of `heights` does not match the length of `indicator_names`. - """ - if not runs: + if runs is None: runs = [None] coverages = [ self._get_combined_coverage_for_single_run( @@ -611,7 +672,7 @@ def _get_combined_coverage_for_single_run( long: tuple = FRANCE_METRO_LONGITUDES, forecast_horizons: list[int] | None = None, ) -> pd.DataFrame: - """ + """(Protected) Get a combined DataFrame of coverage data for a given run considering a list of indicators. This method retrieves and aggregates coverage data for specified indicators, with options @@ -623,8 +684,9 @@ def _get_combined_coverage_for_single_run( run (str): A single runs for each indicator. Format should be "YYYY-MM-DDTHH:MM:SSZ". heights (list[int] | None): A list of heights in meters to filter by (default is None). pressures (list[int] | None): A list of pressures in hPa to filter by (default is None). - intervals (Optional[list[str]]): A list of aggregation periods (default is None). Must be `None` or "" for instant indicators; - otherwise, raises an exception. Defaults to 'P1D' for time-aggregated indicators. + intervals (Optional[list[str]]): A list of aggregation periods (default is None). + Must be `None` or "" for instant indicators ; otherwise, raises an exception. + Defaults to 'P1D' for time-aggregated indicators. lat (tuple): The latitude range as (min_latitude, max_latitude). Defaults to FRANCE_METRO_LATITUDES. long (tuple): The longitude range as (min_longitude, max_longitude). Defaults to FRANCE_METRO_LONGITUDES. forecast_horizons (list[int] | None): A list of forecast horizon values in hours. Defaults to None. @@ -634,16 +696,29 @@ def _get_combined_coverage_for_single_run( Raises: ValueError: If the length of `heights` does not match the length of `indicator_names`. - """ - def _check_params_length(params: list | None, arg_name: str) -> list: - """assert length is ok or raise""" + def _check_params_length(params: list[Any] | None, arg_name: str) -> list[Any]: + """(Protected) + Assert length is ok or raise an error. + + Args: + params: list of parameters. + arg_name: argument name. + + Returns: + The given parameters unchanged. + + Raises: + ValueError: The length of {arg_name} must match the length of indicator_names. + """ if params is None: return [None] * len(indicator_names) if len(params) != len(indicator_names): raise ValueError( - f"The length of {arg_name} must match the length of indicator_names. If you want multiple {arg_name} for a single indicator, create multiple entries in `indicator_names`." + f"The length of {arg_name} must match the length of indicator_names." + f" If you want multiple {arg_name} for a single indicator, create multiple" + " entries in `indicator_names`." ) return params @@ -691,14 +766,16 @@ def _check_params_length(params: list | None, arg_name: str) -> list: ) def _get_forecast_horizons(self, coverage_ids: list[str]) -> list[list[int]]: - """ + """(Protected) Retrieve the times for each coverage_id. - Parameters: - coverage_ids (list[str]): List of coverage IDs. + + Args: + coverage_ids: List of coverage IDs. + Returns: - list[list[int]]: List of times for each coverage ID. + List of times for each coverage ID. """ - indicator_times = [] + indicator_times: list[list[int]] = [] for coverage_id in coverage_ids: times = self.get_coverage_description(coverage_id)["forecast_horizons"] indicator_times.append(times) @@ -708,13 +785,16 @@ def find_common_forecast_horizons( self, list_coverage_id: list[str], ) -> list[int]: - """ - Find common forecast_horizons among coverage IDs. - indicator_names (list[str]): List of indicator names. - run (Optional[str]): Identifies the model inference. Defaults to latest if None. Format "YYYY-MM-DDTHH:MM:SSZ". - intervals (Optional[list[str]]): List of aggregation periods. Must be None for instant indicators, otherwise raises. Defaults to P1D for time-aggregated indicators like TOTAL_PRECIPITATION. + """Find common forecast_horizons among coverage IDs. + + Args: + indicator_names: List of indicator names. + run: Identifies the model inference. Defaults to latest if None. Format "YYYY-MM-DDTHH:MM:SSZ". + intervals: List of aggregation periods. Must be None for instant indicators, otherwise raises. + Defaults to P1D for time-aggregated indicators like TOTAL_PRECIPITATION. + Returns: - list[int]: Common forecast_horizons + List of common forecast_horizons. """ indicator_forecast_horizons = self._get_forecast_horizons(list_coverage_id) @@ -729,13 +809,15 @@ def find_common_forecast_horizons( return sorted(common_forecast_horizons) def _validate_forecast_horizons(self, coverage_ids: list[str], forecast_horizons: list[int]) -> list[str]: - """ + """(Protected) Validate forecast_horizons for a list of coverage IDs. - Parameters: - coverage_ids (list[str]): List of coverage IDs. - forecast_horizons (list[int]): List of time forecasts to validate. + + Args: + coverage_ids: List of coverage IDs. + forecast_horizons: List of time forecasts to validate. + Returns: - list[str]: List of invalid coverage IDs. + List of invalid coverage IDs. """ indicator_forecast_horizons = self._get_forecast_horizons(coverage_ids)