meteole 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of meteole might be problematic. Click here for more details.

meteole/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ import logging
2
+ from importlib.metadata import version
3
+
4
+ from meteole._arome import AromeForecast
5
+ from meteole._arpege import ArpegeForecast
6
+ from meteole._vigilance import Vigilance
7
+
8
+ __all__ = ["AromeForecast", "ArpegeForecast", "Vigilance"]
9
+
10
+ __version__ = version("meteole")
11
+
12
+
13
+ def setup_logger():
14
+ """Setup logger with proper StreamHandler and formatter"""
15
+ logger = logging.getLogger(__name__)
16
+ logger.setLevel(logging.INFO)
17
+
18
+ handler = logging.StreamHandler()
19
+ handler.setLevel(logging.INFO)
20
+
21
+ # formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
22
+ formatter = logging.Formatter("%(message)s")
23
+ handler.setFormatter(formatter)
24
+
25
+ return logger.addHandler(handler)
26
+
27
+
28
+ logger = setup_logger()
meteole/_arome.py ADDED
@@ -0,0 +1,70 @@
1
+ """The interface for the observational data from the meteo-France API.
2
+
3
+ See :
4
+ - https://portail-api.meteofrance.fr/web/fr/api/arome
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import final
11
+
12
+ from meteole.clients import BaseClient, MeteoFranceClient
13
+ from meteole.forecast import Forecast
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ AVAILABLE_AROME_TERRITORY: list[str] = [
18
+ "FRANCE",
19
+ "NCALED",
20
+ "INDIEN",
21
+ "POLYN",
22
+ "GUYANE",
23
+ "ANTIL",
24
+ ]
25
+
26
+ AROME_INSTANT_INDICATORS: list[str] = [
27
+ "GEOMETRIC_HEIGHT__GROUND_OR_WATER_SURFACE",
28
+ "BRIGHTNESS_TEMPERATURE__GROUND_OR_WATER_SURFACE",
29
+ "CONVECTIVE_AVAILABLE_POTENTIAL_ENERGY__GROUND_OR_WATER_SURFACE",
30
+ "WIND_SPEED_GUST__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
31
+ "WIND_SPEED__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
32
+ "RELATIVE_HUMIDITY__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
33
+ "LOW_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
34
+ "HIGH_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
35
+ "MEDIUM_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
36
+ "PRESSURE__GROUND_OR_WATER_SURFACE",
37
+ "TOTAL_PRECIPITATION_RATE__GROUND_OR_WATER_SURFACE",
38
+ "TEMPERATURE__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
39
+ "U_COMPONENT_OF_WIND_GUST__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
40
+ "U_COMPONENT_OF_WIND__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
41
+ "V_COMPONENT_OF_WIND_GUST__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
42
+ "V_COMPONENT_OF_WIND__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
43
+ ]
44
+
45
+ AROME_OTHER_INDICATORS: list[str] = [
46
+ "TOTAL_WATER_PRECIPITATION__GROUND_OR_WATER_SURFACE",
47
+ "TOTAL_SNOW_PRECIPITATION__GROUND_OR_WATER_SURFACE",
48
+ "TOTAL_PRECIPITATION__GROUND_OR_WATER_SURFACE",
49
+ ]
50
+
51
+
52
+ @final
53
+ class AromeForecast(Forecast):
54
+ """Access the AROME numerical forecast data."""
55
+
56
+ # Model constants
57
+ MODEL_NAME: str = "arome"
58
+ INDICATORS: list[str] = AROME_INSTANT_INDICATORS + AROME_OTHER_INDICATORS
59
+ INSTANT_INDICATORS: list[str] = AROME_INSTANT_INDICATORS
60
+ BASE_ENTRY_POINT: str = "wcs/MF-NWP-HIGHRES-AROME"
61
+ DEFAULT_TERRITORY: str = "FRANCE"
62
+ DEFAULT_PRECISION: float = 0.01
63
+ CLIENT_CLASS: type[BaseClient] = MeteoFranceClient
64
+
65
+ def _validate_parameters(self):
66
+ """Assert the parameters are valid."""
67
+ if self.precision not in [0.01, 0.025]:
68
+ raise ValueError("Parameter `precision` must be in (0.01, 0.025). It is inferred from argument `territory`")
69
+ if self.territory not in AVAILABLE_AROME_TERRITORY:
70
+ raise ValueError(f"Parameter `territory` must be in {AVAILABLE_AROME_TERRITORY}")
meteole/_arpege.py ADDED
@@ -0,0 +1,119 @@
1
+ """The interface for the observational data from the meteo-France API.
2
+
3
+ See :
4
+ - https://portail-api.meteofrance.fr/web/fr/api/arpege
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, final
10
+
11
+ from meteole.clients import BaseClient, MeteoFranceClient
12
+ from meteole.forecast import Forecast
13
+
14
+ AVAILABLE_ARPEGE_TERRITORY: list[str] = ["EUROPE", "GLOBE", "ATOURX", "EURAT"]
15
+
16
+ ARPEGE_INSTANT_INDICATORS: list[str] = [
17
+ "GEOMETRIC_HEIGHT__GROUND_OR_WATER_SURFACE",
18
+ "BRIGHTNESS_TEMPERATURE__GROUND_OR_WATER_SURFACE",
19
+ "CONVECTIVE_AVAILABLE_POTENTIAL_ENERGY__GROUND_OR_WATER_SURFACE",
20
+ "SPECIFIC_CLOUD_ICE_WATER_CONTENT__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
21
+ "SPECIFIC_CLOUD_ICE_WATER_CONTENT__ISOBARIC_SURFACE",
22
+ "WIND_SPEED_GUST__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
23
+ "WIND_SPEED__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
24
+ "WIND_SPEED__ISOBARIC_SURFACE",
25
+ "RELATIVE_HUMIDITY__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
26
+ "RELATIVE_HUMIDITY__ISOBARIC_SURFACE",
27
+ "PLANETARY_BOUNDARY_LAYER_HEIGHT__GROUND_OR_WATER_SURFACE",
28
+ "LOW_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
29
+ "HIGH_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
30
+ "MEDIUM_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
31
+ "PRESSURE__GROUND_OR_WATER_SURFACE",
32
+ "PRESSURE__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
33
+ "PRESSURE__MEAN_SEA_LEVEL",
34
+ "ABSOLUTE_VORTICITY__ISOBARIC_SURFACE",
35
+ "DEW_POINT_TEMPERATURE__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
36
+ "DEW_POINT_TEMPERATURE__ISOBARIC_SURFACE",
37
+ "TURBULENT_KINETIC_ENERGY__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
38
+ "TURBULENT_KINETIC_ENERGY__ISOBARIC_SURFACE",
39
+ "MAXIMUM_TEMPERATURE__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
40
+ "MINIMUM_TEMPERATURE__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
41
+ "PSEUDO_ADIABATIC_POTENTIAL_TEMPERATURE__ISOBARIC_SURFACE",
42
+ "POTENTIAL_VORTICITY__ISOBARIC_SURFACE",
43
+ "TEMPERATURE__GROUND_OR_WATER_SURFACE",
44
+ "TEMPERATURE__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
45
+ "TEMPERATURE__ISOBARIC_SURFACE",
46
+ "U_COMPONENT_OF_WIND_GUST__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
47
+ "U_COMPONENT_OF_WIND__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
48
+ "U_COMPONENT_OF_WIND__ISOBARIC_SURFACE",
49
+ "U_COMPONENT_OF_WIND__POTENTIAL_VORTICITY_SURFACE_1500",
50
+ "U_COMPONENT_OF_WIND__POTENTIAL_VORTICITY_SURFACE_2000",
51
+ "VERTICAL_VELOCITY_PRESSURE__ISOBARIC_SURFACE",
52
+ "V_COMPONENT_OF_WIND_GUST__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
53
+ "V_COMPONENT_OF_WIND__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
54
+ "V_COMPONENT_OF_WIND__ISOBARIC_SURFACE",
55
+ "V_COMPONENT_OF_WIND__POTENTIAL_VORTICITY_SURFACE_1500",
56
+ "V_COMPONENT_OF_WIND__POTENTIAL_VORTICITY_SURFACE_2000",
57
+ "GEOPOTENTIAL__ISOBARIC_SURFACE",
58
+ "TOTAL_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
59
+ ]
60
+
61
+ ARPEGE_OTHER_INDICATORS: list[str] = [
62
+ "TOTAL_WATER_PRECIPITATION__GROUND_OR_WATER_SURFACE",
63
+ "TOTAL_SNOW_PRECIPITATION__GROUND_OR_WATER_SURFACE",
64
+ "TOTAL_PRECIPITATION__GROUND_OR_WATER_SURFACE",
65
+ "DOWNWARD_SHORT_WAVE_RADIATION_FLUX__GROUND_OR_WATER_SURFACE",
66
+ "SHORT_WAVE_RADIATION_FLUX__GROUND_OR_WATER_SURFACE",
67
+ ]
68
+
69
+
70
+ @final
71
+ class ArpegeForecast(Forecast):
72
+ """Access the ARPEGE numerical forecast data."""
73
+
74
+ # Model constants
75
+ MODEL_NAME: str = "arpege"
76
+ INDICATORS: list[str] = ARPEGE_INSTANT_INDICATORS + ARPEGE_OTHER_INDICATORS
77
+ INSTANT_INDICATORS: list[str] = ARPEGE_INSTANT_INDICATORS
78
+ BASE_ENTRY_POINT: str = "wcs/MF-NWP-GLOBAL-ARPEGE"
79
+ DEFAULT_TERRITORY: str = "EUROPE"
80
+ RELATION_TERRITORY_TO_PREC_ARPEGE: dict[str, float] = {"EUROPE": 0.1, "GLOBE": 0.25, "ATOURX": 0.1, "EURAT": 0.05}
81
+ CLIENT_CLASS: type[BaseClient] = MeteoFranceClient
82
+
83
+ def __init__(
84
+ self,
85
+ client: BaseClient | None = None,
86
+ *,
87
+ territory: str = "EUROPE",
88
+ **kwargs: Any,
89
+ ):
90
+ """
91
+ Initializes an ArpegeForecast object for accessing ARPEGE forecast data.
92
+
93
+ The `precision` of the forecast is inferred from the specified `territory`.
94
+
95
+ Args:
96
+ territory (str, optional): The ARPEGE territory to fetch. Defaults to "EUROPE".
97
+ api_key (str | None, optional): The API key for authentication. Defaults to None.
98
+ token (str | None, optional): The API token for authentication. Defaults to None.
99
+ application_id (str | None, optional): The Application ID for authentication. Defaults to None.
100
+ cache_dir (str | None, optional): Path to the cache directory. Defaults to None.
101
+ If not provided, the cache directory is set to "/tmp/cache".
102
+
103
+ Notes:
104
+ - See `MeteoFranceClient` for additional details on the parameters `api_key`, `token`,
105
+ and `application_id`.
106
+ - Available territories are listed in the `AVAILABLE_TERRITORY` constant.
107
+
108
+ """
109
+ super().__init__(
110
+ client=client,
111
+ territory=territory,
112
+ precision=self.RELATION_TERRITORY_TO_PREC_ARPEGE[territory],
113
+ **kwargs,
114
+ )
115
+
116
+ def _validate_parameters(self):
117
+ """Assert the parameters are valid."""
118
+ if self.territory not in AVAILABLE_ARPEGE_TERRITORY:
119
+ raise ValueError(f"The parameter precision must be in {AVAILABLE_ARPEGE_TERRITORY}")
meteole/_vigilance.py ADDED
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from io import BytesIO
5
+ from typing import Any, final
6
+
7
+ import matplotlib.image as mpimg
8
+ import matplotlib.pyplot as plt
9
+ import pandas as pd
10
+ from requests import Response
11
+
12
+ from meteole.clients import BaseClient, MeteoFranceClient
13
+ from meteole.errors import MissingDataError
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @final
19
+ class Vigilance:
20
+ """Wrapper around the meteo-France API for the vigilance data.
21
+ Ressources are:
22
+ - textesvigilance
23
+ - cartevigilance
24
+
25
+ Documentation
26
+ -------------
27
+ See:
28
+ - https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesVigilance
29
+ - https://donneespubliques.meteofrance.fr/client/document/descriptiftechnique_vigilancemetropole_
30
+ donneespubliques_v4_20230911_307.pdf
31
+ """
32
+
33
+ API_VERSION: str = "v1"
34
+ VIGILANCE_BASE_PATH: str = "DPVigilance/"
35
+ PHENOMENO_IDS: dict[str, str] = {
36
+ "1": "wind",
37
+ "2": "rain",
38
+ "3": "storm",
39
+ "4": "flood",
40
+ "5": "snow_ice",
41
+ "6": "heat_wave",
42
+ "7": "extreme_cold",
43
+ "8": "avalanches",
44
+ "9": "waves_submergence",
45
+ }
46
+
47
+ def __init__(
48
+ self,
49
+ client: BaseClient | None = None,
50
+ **kwargs: Any,
51
+ ) -> None:
52
+ """TODO"""
53
+ if client is not None:
54
+ self._client = client
55
+ else:
56
+ # Try to instantiate it (can be user friendly)
57
+ self._client = MeteoFranceClient(**kwargs)
58
+
59
+ def get_bulletin(self) -> dict[str, Any]:
60
+ """
61
+ Retrieve the vigilance bulletin.
62
+
63
+ Returns:
64
+ dict: a Dict representing the vigilance bulletin
65
+ """
66
+
67
+ path: str = self.VIGILANCE_BASE_PATH + self.API_VERSION + "/textesvigilance/encours"
68
+ logger.debug(f"GET {path}")
69
+
70
+ try:
71
+ resp: Response = self._client.get(path)
72
+ return resp.json()
73
+
74
+ except MissingDataError as e:
75
+ if "no matching blob" in e.message:
76
+ logger.warning("Ongoing vigilance requires no publication")
77
+ else:
78
+ logger.error(f"Unexpected error: {e}")
79
+ return {}
80
+ except Exception as e:
81
+ logger.error(f"Unexpected error: {e}")
82
+ return {}
83
+
84
+ def get_map(self) -> dict[str, Any]:
85
+ """
86
+ Get the vigilance map with predicted risk displayed.
87
+
88
+ Returns:
89
+ dict: a Dict with the predicted risk.
90
+ """
91
+ path: str = self.VIGILANCE_BASE_PATH + self.API_VERSION + "/cartevigilance/encours"
92
+ logger.debug(f"GET {path}")
93
+
94
+ resp: Response = self._client.get(path)
95
+
96
+ return resp.json()
97
+
98
+ def get_phenomenon(self) -> tuple[pd.DataFrame, pd.DataFrame]:
99
+ """
100
+ Get risk prediction by phenomenon and by domain.
101
+
102
+ Returns:
103
+ pd.DataFrame: a DataFrame with phenomenon by id
104
+ pd.DataFrame: a DataFrame with phenomenon by domain
105
+ """
106
+ df_carte = pd.DataFrame(self.get_map())
107
+ periods_data = df_carte.loc["periods", "product"]
108
+ df_periods = pd.json_normalize(periods_data)
109
+
110
+ df_j = df_periods[df_periods["echeance"] == "J"]
111
+ df_j1 = df_periods[df_periods["echeance"] == "J1"]
112
+
113
+ df_phenomenon_j = pd.json_normalize(df_j["per_phenomenon_items"].explode())
114
+ df_phenomenon_j1 = pd.json_normalize(df_j1["per_phenomenon_items"].explode())
115
+ df_phenomenon_j["echeance"] = "J"
116
+ df_phenomenon_j1["echeance"] = "J1"
117
+
118
+ df_phenomenon = pd.concat([df_phenomenon_j, df_phenomenon_j1]).reset_index(drop=True)
119
+ df_phenomenon["phenomenon_libelle"] = df_phenomenon["phenomenon_id"].map(self.PHENOMENO_IDS)
120
+
121
+ df_timelaps_j = pd.json_normalize(df_j["timelaps.domain_ids"].explode())
122
+ df_timelaps_j1 = pd.json_normalize(df_j1["timelaps.domain_ids"].explode())
123
+ df_timelaps_j["echeance"] = "J"
124
+ df_timelaps_j1["echeance"] = "J1"
125
+
126
+ df_timelaps = pd.concat([df_timelaps_j, df_timelaps_j1]).reset_index(drop=True)
127
+
128
+ return df_phenomenon, df_timelaps
129
+
130
+ def get_vignette(self) -> None:
131
+ """
132
+ Get png.
133
+ """
134
+ path: str = self.VIGILANCE_BASE_PATH + self.API_VERSION + "/vignettenationale-J-et-J1/encours"
135
+
136
+ logger.debug(f"GET {path}")
137
+ resp: Response = self._client.get(path)
138
+
139
+ if resp.status_code == 200:
140
+ content: str | None = resp.headers.get("content-disposition")
141
+ if content is not None:
142
+ filename: str = content.split("filename=")[1]
143
+ filename = filename.strip('"')
144
+
145
+ with open(filename, "wb") as f:
146
+ f.write(resp.content)
147
+
148
+ img = mpimg.imread(BytesIO(resp.content), format="png")
149
+ plt.imshow(img)
150
+ plt.axis("off")
151
+ plt.show()
152
+ else:
153
+ resp.raise_for_status()
meteole/clients.py ADDED
@@ -0,0 +1,228 @@
1
+ """API client classes"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from abc import ABC, abstractmethod
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import requests
13
+ from requests import Response, Session
14
+
15
+ from meteole.errors import GenericMeteofranceApiError, MissingDataError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class HttpStatus(int, Enum):
21
+ """Http status codes"""
22
+
23
+ OK: int = 200
24
+ BAD_REQUEST: int = 400
25
+ UNAUTHORIZED: int = 401
26
+ FORBIDDEN: int = 403
27
+ NOT_FOUND: int = 404
28
+ TOO_MANY_REQUESTS: int = 429
29
+ INTERNAL_ERROR: int = 500
30
+ BAD_GATEWAY: int = 502
31
+ UNAVAILABLE: int = 503
32
+ GATEWAY_TIMEOUT: int = 504
33
+
34
+
35
+ class BaseClient(ABC):
36
+ """TODO"""
37
+
38
+ @abstractmethod
39
+ def get(self, path: str, *, params: dict[str, Any] | None = None, max_retries: int = 5) -> Response:
40
+ """TODO"""
41
+ raise NotImplementedError
42
+
43
+
44
+ class MeteoFranceClient(BaseClient):
45
+ """A client for interacting with the Meteo France API.
46
+
47
+ This class handles the connection setup and token refreshment required for
48
+ authenticating and making requests to the Meteo France API.
49
+
50
+ Attributes:
51
+ api_key (str | None): The API key for accessing the Meteo France API.
52
+ token (str | None): The authentication token for accessing the API.
53
+ application_id (str | None): The application ID used for identification.
54
+ verify (Path | None): The path to a file or directory of trusted CA certificates for SSL verification.
55
+ """
56
+
57
+ # Class constants
58
+ API_BASE_URL: str = "https://public-api.meteofrance.fr/public/"
59
+ TOKEN_URL: str = "https://portail-api.meteofrance.fr/token"
60
+ GET_TOKEN_TIMEOUT_SEC: int = 10
61
+ INVALID_JWT_ERROR_CODE: str = "900901"
62
+ RETRY_DELAY_SEC: int = 5
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ token: str | None = None,
68
+ api_key: str | None = None,
69
+ application_id: str | None = None,
70
+ certs_path: Path | None = None,
71
+ ) -> None:
72
+ """
73
+ Initializes the MeteoFranceClient object.
74
+
75
+ Args:
76
+ api_key (str | None): The API key for accessing the Meteo France API.
77
+ token (str | None): The authentication token for accessing the API.
78
+ application_id (str | None): The application ID used for identification.
79
+ verify (Path | None): The path to a file or directory of trusted CA certificates for SSL verification.
80
+ """
81
+ self._token = token
82
+ self._api_key = api_key
83
+ self._application_id = application_id
84
+ self._verify: str | None = str(certs_path) if certs_path is not None else None
85
+
86
+ self._session = Session()
87
+
88
+ self._token_expired: bool = False
89
+
90
+ # Initialize the requests session object
91
+ self._connect()
92
+
93
+ def get(self, path: str, *, params: dict[str, Any] | None = None, max_retries: int = 5) -> Response:
94
+ """
95
+ Makes a GET request to the API with optional retries.
96
+
97
+ Args:
98
+ url (str): The URL to send the GET request to.
99
+ params (dict, optional): The query parameters to include in the request. Defaults to None.
100
+ max_retries (int, optional): The maximum number of retry attempts in case of failure. Defaults to 5.
101
+
102
+ Returns:
103
+ requests.Response: The response returned by the API.
104
+
105
+ """
106
+ url: str = self.API_BASE_URL + path
107
+ attempt: int = 0
108
+ logger.debug(f"GET {url}")
109
+
110
+ while attempt < max_retries:
111
+ # HTTP GET request
112
+ resp: Response = self._session.get(url, params=params, verify=self._verify)
113
+
114
+ if resp.status_code == HttpStatus.OK:
115
+ logger.debug("Successful request")
116
+ return resp
117
+
118
+ elif self._is_token_expired(resp):
119
+ logger.info("Token expired, requesting a new one")
120
+
121
+ # Refresh the cached token
122
+ self._token = self._get_token()
123
+
124
+ # Reconnect with the new token
125
+ self._connect()
126
+
127
+ elif resp.status_code == HttpStatus.FORBIDDEN:
128
+ logger.error("Access forbidden")
129
+ raise GenericMeteofranceApiError(resp.text)
130
+
131
+ elif resp.status_code == HttpStatus.BAD_REQUEST:
132
+ logger.error("Parameter error")
133
+ raise GenericMeteofranceApiError(resp.text)
134
+
135
+ elif resp.status_code == HttpStatus.NOT_FOUND:
136
+ logger.error("Missing data")
137
+ raise MissingDataError(resp.text)
138
+
139
+ elif (
140
+ resp.status_code == HttpStatus.BAD_GATEWAY
141
+ or resp.status_code == HttpStatus.UNAVAILABLE
142
+ or resp.status_code == HttpStatus.GATEWAY_TIMEOUT
143
+ ):
144
+ logger.error("Service not available")
145
+ time.sleep(self.RETRY_DELAY_SEC)
146
+ attempt += 1
147
+ logger.info(f"Retrying... Attempt {attempt} of {max_retries}")
148
+ continue
149
+
150
+ raise GenericMeteofranceApiError(f"Failed to get a successful response from API after {attempt} retries")
151
+
152
+ def _connect(self):
153
+ """Connect to the MeteoFrance API.
154
+
155
+ If the API key is provided, it is used to authenticate the user.
156
+ If the token is provided, it is used to authenticate the user.
157
+ If the application ID is provided, a token is requested from the API.
158
+ """
159
+ if self._api_key is None and self._token is None:
160
+ if self._application_id is None:
161
+ raise ValueError("api_key or token or application_id must be provided")
162
+
163
+ # Connection with application_id
164
+ self._token = self._get_token()
165
+
166
+ if self._api_key is not None:
167
+ logger.debug("using api key")
168
+
169
+ # Connection with api_key
170
+ self._session.headers.update({"apikey": self._api_key})
171
+
172
+ else:
173
+ logger.debug("using token")
174
+
175
+ # Connection with token
176
+ self._session.headers.update({"Authorization": f"Bearer {self._token}"})
177
+
178
+ def _get_token(self) -> str:
179
+ """request a token from the meteo-France API.
180
+
181
+ The token lasts 1 hour, and is used to authenticate the user.
182
+ If a new token is requested before the previous one expires, the previous one is invalidated.
183
+ A local cache is used to avoid requesting a new token at each run of the script.
184
+ """
185
+ if self._token_expired is False and self._token is not None:
186
+ # Use cached token
187
+ token: str = self._token
188
+
189
+ elif self._token_expired is True and self._application_id is None:
190
+ # Can do nothing
191
+ raise ValueError("The 'application_id' is unknown, can't get a new token")
192
+
193
+ else:
194
+ # Retrieve a new token
195
+
196
+ # Seems useless (TODO remove if it's True):
197
+ # params: dict[str, str] = {"grant_type": "client_credentials"}
198
+ headers: dict[str, str] = {"Authorization": "Basic " + str(self._application_id)}
199
+
200
+ resp: Response = requests.post(
201
+ self.TOKEN_URL,
202
+ # params=params,
203
+ headers=headers,
204
+ timeout=self.GET_TOKEN_TIMEOUT_SEC,
205
+ verify=self._verify,
206
+ )
207
+ token = resp.json()["access_token"]
208
+
209
+ return token
210
+
211
+ def _is_token_expired(self, response: Response) -> bool:
212
+ """Check if the token is expired.
213
+
214
+ Returns
215
+ -------
216
+ bool
217
+ True if the token is expired, False otherwise.
218
+ """
219
+ result: bool = False
220
+
221
+ if response.status_code == HttpStatus.UNAUTHORIZED and "application/json" in response.headers["Content-Type"]:
222
+ error: str = response.json()["code"]
223
+
224
+ if error == self.INVALID_JWT_ERROR_CODE:
225
+ result = True
226
+ self._token_expired = True
227
+
228
+ return result
meteole/errors.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import xmltodict
4
+
5
+
6
+ class GenericMeteofranceApiError(Exception):
7
+ """Exception raised errors in the input parameters where a required field is missing.
8
+
9
+ Args:
10
+ message (str): Human-readable string descipting the exceptetion.
11
+ description (str): More detailed description of the error."""
12
+
13
+ def init(self, text: str) -> None:
14
+ """Initialize the exception with an error message parsed from an XML
15
+ string.
16
+
17
+ Args:
18
+ text (str): XML string containing the error details,
19
+ expected to follow a specific schema with 'am:fault' as the root
20
+ element and 'am:message' and 'am:description' as child elements."""
21
+
22
+ # parse the error message with xmltodict
23
+ data = xmltodict.parse(text)
24
+ message = data["am:fault"]["am:message"]
25
+ description = data["am:fault"]["am:description"]
26
+ self.message = f"{message}\n {description}"
27
+ super().__init__(self.message)
28
+
29
+
30
+ class MissingDataError(Exception):
31
+ """Exception raised errors in the input data is missing"""
32
+
33
+ def init(self, text: str) -> None:
34
+ """Initialize the exception with an error message parsed from an XML
35
+ string.
36
+
37
+ Args:
38
+ text (str): XML string containing the error details,
39
+ expected to follow a specific schema with 'am:fault' as the root
40
+ element and 'am:message' and 'am:description' as child elements."""
41
+
42
+ # parse the error message with xmltodict
43
+ try:
44
+ data = xmltodict.parse(text)
45
+ exception = data["mw:fault"]["mw:description"]["ns0:ExceptionReport"]["ns0:Exception"]
46
+ code = exception["@exceptionCode"]
47
+ locator = exception["@locator"]
48
+ exception_text = exception["ns0:ExceptionText"]
49
+ message = f"Error code: {code}\nLocator: {locator}\nText: {exception_text}"
50
+ except Exception:
51
+ message = text
52
+ self.message = message
53
+ super().__init__(self.message)