-
Notifications
You must be signed in to change notification settings - Fork 10
Hapi REST provider #285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Hapi REST provider #285
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
42e42f1
Hapi Provider Skeleton
RichardHitier 271a0b6
Add capabilities() endpoint
RichardHitier 6cbfa03
Add hapi() endpoint
RichardHitier 0161d4f
Add catalog() endpoint
RichardHitier de6fc41
Add info() and status checking
RichardHitier c03a1fd
Change 'dataset'/'id' name guessing
RichardHitier 76d65cc
Test bad info request, detailed exceptions
RichardHitier eba48f3
Get parameters info
RichardHitier 6c24b07
Now provider.info() catches exceptions
RichardHitier 3792cc8
Remove duplicated
RichardHitier 7efad80
Catch and check data() endpoint errors
RichardHitier ab080c0
Change 'parameters' get_data() arg to be required
RichardHitier 2f5fd13
Now data() endpoint returns SpeazyVariable(s)
RichardHitier c76373e
Refactor: extract static utils
RichardHitier 7aee077
Refactor: type hints and Exception handling
RichardHitier d284847
Fix error catching
RichardHitier bfcf046
Fix the binary mode after hapi.codecs changes
RichardHitier 3eaee11
Fix pep8 issues
RichardHitier File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| from .provider import HapiProvider | ||
| from .exceptions import ( | ||
| HapiError, | ||
| HapiRequestError, | ||
| HapiServerError, | ||
| HapiNoData, | ||
| ) | ||
|
|
||
| __all__ = [ | ||
| "HapiProvider", | ||
| "HapiError", | ||
| "HapiRequestError", | ||
| "HapiServerError", | ||
| "HapiNoData", | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| from enum import Enum | ||
| import io | ||
| from json import JSONDecodeError | ||
| from typing import Dict, List, Mapping, Optional | ||
| from urllib.parse import urlencode | ||
|
|
||
| from speasy.core import http | ||
| from speasy.core.hapi.parser import _parse_hapi_csv | ||
| from speasy.products.variable import SpeasyVariable | ||
|
|
||
| from .exceptions import ( | ||
| HapiRequestError, HapiServerError, HapiNoData | ||
| ) | ||
|
|
||
|
|
||
| class HapiEndpoint(Enum): | ||
| CAPABILITIES = "capabilities" | ||
| CATALOG = "catalog" | ||
| ABOUT = "about" | ||
| INFO = "info" | ||
| DATA = "data" | ||
|
|
||
|
|
||
| def _fetch_response(url: str): | ||
| response = http.get(url) | ||
| _check_response(response) | ||
| return response | ||
|
|
||
|
|
||
| def _check_hapi_status(data: Dict) -> None: | ||
| code = data["status"]["code"] | ||
| message = data["status"]["message"] | ||
| if code == 1201: | ||
| raise HapiNoData() | ||
| elif 1400 <= code < 1500: | ||
| raise HapiRequestError(code, message) | ||
| elif code >= 1500: | ||
| raise HapiServerError(code, message) | ||
|
|
||
|
|
||
| def _check_http_status(status_code: int, text: str) -> None: | ||
| if 400 <= status_code < 500: | ||
| raise HapiRequestError(status_code, text) | ||
| elif status_code >= 500: | ||
| raise HapiServerError(status_code, text) | ||
|
|
||
|
|
||
| def _check_response(response) -> None: | ||
| try: | ||
| _check_hapi_status(response.json()) | ||
| except (JSONDecodeError, KeyError): | ||
| _check_http_status(response.status_code, response.text) | ||
|
|
||
|
|
||
| class HapiClient: | ||
| def __init__(self, server_url: str): | ||
| self.server_url = server_url.rstrip('/') | ||
| self._dataset_param_name = self._init_dataset_param_name() | ||
|
|
||
| def _init_dataset_param_name(self) -> str: | ||
| """ Starting from HAPI-3.0, 'id' parameter becomes 'dataset' | ||
| set by major version number | ||
| """ | ||
| version = self.get_capabilities().get("HAPI") | ||
|
|
||
| if not version: | ||
| raise RuntimeError("HAPI version not provided by server") | ||
|
|
||
| major = int(version.split('.')[0]) | ||
|
|
||
| if major == 2: | ||
| return "id" | ||
| elif major == 3: | ||
| return "dataset" | ||
|
|
||
| raise RuntimeError(f"Unsupported HAPI version: {version}") | ||
|
|
||
| def _fetch_variables(self, query_parameters: Dict) -> Mapping[str, SpeasyVariable]: | ||
| parameters = query_parameters.get("parameters", []) | ||
| url = self._build_url(HapiEndpoint.DATA, query_parameters) | ||
| f = io.BytesIO(_fetch_response(url).text.encode("utf-8")) | ||
| return _parse_hapi_csv(f, parameters) | ||
|
|
||
| def _build_url( | ||
| self, | ||
| endpoint: Optional[HapiEndpoint] = None, | ||
| query_parameters: Optional[Dict] = None | ||
| ) -> str: | ||
| base = f"{self.server_url}/hapi" | ||
| url = f"{base}/{endpoint.value}" if endpoint else base | ||
|
|
||
| # Flatten "parameters" into a comma-separated query string | ||
| if query_parameters: | ||
| query_params_copy = query_parameters.copy() | ||
| parameters = query_params_copy.get("parameters") | ||
| if parameters: | ||
| query_params_copy["parameters"] = ",".join(parameters) | ||
| else: | ||
| query_params_copy.pop("parameters", None) | ||
|
|
||
| url = f"{url}?{urlencode(query_params_copy)}" | ||
|
|
||
| return url | ||
|
|
||
| def _endpoint_to_json( | ||
| self, | ||
| endpoint: HapiEndpoint, | ||
| query_parameters: Optional[Dict] = None | ||
| ) -> Dict: | ||
| url = self._build_url(endpoint, query_parameters) | ||
| return _fetch_response(url).json() | ||
|
|
||
| def get_hapi(self) -> str: | ||
| url = self._build_url() | ||
| with http.urlopen(url) as response: | ||
| html_page = response.text | ||
| return html_page | ||
|
|
||
| def get_capabilities(self) -> Dict: | ||
| return self._endpoint_to_json(HapiEndpoint.CAPABILITIES) | ||
|
|
||
| def get_catalog(self) -> Dict: | ||
| return self._endpoint_to_json(HapiEndpoint.CATALOG) | ||
|
|
||
| def get_about(self) -> Dict: | ||
| return self._endpoint_to_json(HapiEndpoint.ABOUT) | ||
|
|
||
| def get_info(self, dataset: str, parameters: Optional[List[str]] = None) -> Dict: | ||
| query_params = { | ||
| self._dataset_param_name: dataset, | ||
| } | ||
| if parameters is not None: | ||
| query_params["parameters"] = parameters # List[str], will be flatten in _build_url | ||
|
|
||
| return self._endpoint_to_json(HapiEndpoint.INFO, query_params) | ||
|
|
||
| def get_data( | ||
| self, dataset: str, start: str, stop: str, parameters: List[str] | ||
| ) -> Mapping[str, SpeasyVariable]: | ||
| query_params = { | ||
| self._dataset_param_name: dataset, | ||
| "parameters": parameters, | ||
| "start": start, | ||
| "stop": stop, | ||
| "format": "csv", | ||
| "include": "header", | ||
| } | ||
| return self._fetch_variables(query_params) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| class HapiError(Exception): | ||
| pass | ||
|
|
||
|
|
||
| class HapiRequestError(HapiError): | ||
| """4xx — Client Error (unkown dataset, bad date format, ...) """ | ||
| def __init__(self, code: int, message: str): | ||
| self.code = code | ||
| self.message = message | ||
| super().__init__(message) | ||
|
|
||
|
|
||
| class HapiServerError(HapiError): | ||
| """5xx — Server Error """ | ||
| def __init__(self, code: int, message: str): | ||
| self.code = code | ||
| self.message = message | ||
| super().__init__(message) | ||
|
|
||
|
|
||
| class HapiNoData(HapiError): | ||
| """1201 — Valid request, but no available data on time range""" | ||
| pass |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import io | ||
| from typing import List, Mapping | ||
| from speasy.core.codecs.codec_interface import CodecInterface | ||
| from speasy.core.codecs.codecs_registry import get_codec | ||
| from speasy.core.hapi.exceptions import HapiError | ||
| from speasy.products.variable import SpeasyVariable | ||
|
|
||
|
|
||
| def _parse_hapi_csv( | ||
| file: io.IOBase, parameters: List[str] | ||
| ) -> Mapping[str, SpeasyVariable]: | ||
| """Converts the CSV returned by /data into a SpeasyVariable. | ||
| """ | ||
| if not parameters: | ||
| raise HapiError( | ||
| f"Wrong 'parameters' argument to hapi.load_variables: {parameters}" | ||
| ) | ||
| hapi_csv_codec: CodecInterface = get_codec('hapi/csv') | ||
| variables = hapi_csv_codec.load_variables(file=file, variables=parameters, | ||
| disable_cache=True) | ||
| return variables |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| from typing import List, Mapping, Optional | ||
|
|
||
| from speasy.products.variable import SpeasyVariable | ||
|
|
||
| from .client import HapiClient | ||
|
|
||
|
|
||
| class HapiProvider: | ||
| def __init__(self, server_url: str): | ||
| self.hapi_client = HapiClient(server_url) | ||
|
|
||
| def hapi(self) -> str: | ||
| return self.hapi_client.get_hapi() | ||
|
|
||
| def capabilities(self) -> dict: | ||
| return self.hapi_client.get_capabilities() | ||
|
|
||
| def catalog(self) -> dict: | ||
| return self.hapi_client.get_catalog() | ||
|
|
||
| def about(self) -> dict: | ||
| return self.hapi_client.get_about() | ||
|
|
||
| def info(self, dataset: str, parameters: Optional[List] = None) -> dict: | ||
| return self.hapi_client.get_info(dataset, parameters) | ||
|
|
||
| def data(self, dataset: str, start: str, stop: str, | ||
| parameters: List[str]) -> Mapping[str, SpeasyVariable]: | ||
| return self.hapi_client.get_data(dataset, start, stop, parameters) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.