diff --git a/src/enlyze/api_client/client.py b/src/enlyze/api_client/client.py index a382293..2de1977 100644 --- a/src/enlyze/api_client/client.py +++ b/src/enlyze/api_client/client.py @@ -1,4 +1,5 @@ import json +from enum import Enum from functools import cache from http import HTTPStatus from typing import Any, Iterator, Type, TypeVar @@ -18,6 +19,11 @@ USER_AGENT_NAME_VERSION_SEPARATOR = "/" +class RequestMethod(str, Enum): + GET = "get" + POST = "post" + + @cache def _construct_user_agent( *, user_agent: str = USER_AGENT, version: str = VERSION @@ -62,9 +68,12 @@ def _full_url(self, api_path: str) -> str: """Construct full URL from relative URL""" return str(self._client.build_request("", api_path).url) - def get(self, api_path: str | httpx.URL, **kwargs: Any) -> Any: - """Wraps :meth:`httpx.Client.get` with defensive error handling + def _request( + self, method: RequestMethod, api_path: str | httpx.URL, **kwargs: Any + ) -> Any: + """Wraps :meth:`httpx.Client.request` with defensive error handling + :param method: HTTP method for the request :param api_path: Relative URL path inside the API name space (or a full URL) :returns: JSON payload of the response as Python object @@ -74,8 +83,9 @@ def get(self, api_path: str | httpx.URL, **kwargs: Any) -> Any: :raises: :exc:`~enlyze.errors.EnlyzeError` on non-JSON payload """ + try: - response = self._client.get(api_path, **kwargs) + response = self._client.request(method, api_path, **kwargs) except Exception as e: raise EnlyzeError( "Couldn't read from the ENLYZE platform API " @@ -104,11 +114,44 @@ def get(self, api_path: str | httpx.URL, **kwargs: Any) -> Any: f"(GET {self._full_url(api_path)})", ) from e - def get_paginated( - self, api_path: str | httpx.URL, model: Type[T], **kwargs: Any + def get(self, api_path: str | httpx.URL, **kwargs: Any) -> Any: + """Wraps :meth:`httpx.Client.get` with defensive error handling + + :param api_path: Relative URL path inside the API name space (or a full URL) + + :returns: JSON payload of the response as Python object + + :raises: :exc:`~enlyze.errors.EnlyzeError` on request failure + :raises: :exc:`~enlyze.errors.EnlyzeError` on non-2xx status code + :raises: :exc:`~enlyze.errors.EnlyzeError` on non-JSON payload + + """ + return self._request(RequestMethod.GET, api_path, **kwargs) + + def post(self, api_path: str | httpx.URL, **kwargs: Any) -> Any: + """Wraps :meth:`httpx.Client.post` with defensive error handling + + :param api_path: Relative URL path inside the API name space (or a full URL) + + :returns: JSON payload of the response as Python object + + :raises: :exc:`~enlyze.errors.EnlyzeError` on request failure + :raises: :exc:`~enlyze.errors.EnlyzeError` on non-2xx status code + :raises: :exc:`~enlyze.errors.EnlyzeError` on non-JSON payload + + """ + return self._request(RequestMethod.POST, api_path, **kwargs) + + def _request_paginated( + self, + method: RequestMethod, + api_path: str | httpx.URL, + model: Type[T], + **kwargs: Any, ) -> Iterator[T]: - """Retrieve objects from a paginated ENLYZE platform API endpoint via HTTP GET + """Retrieve objects from a paginated ENLYZE platform API endpoint + :param method: HTTP method of request :param api_path: Relative URL path inside the ENLYZE platform API :param model: API response model class derived from :class:`~enlyze.api_client.models.PlatformApiModel` @@ -117,17 +160,22 @@ def get_paginated( :raises: :exc:`~enlyze.errors.EnlyzeError` on invalid pagination schema :raises: :exc:`~enlyze.errors.EnlyzeError` on invalid data schema - :raises: see :py:meth:`get` for more errors raised by this method + :raises: see :py:meth:`request` for more errors raised by this method """ - url = api_path params = kwargs.pop("params", {}) + body_json = kwargs.pop("json", None) while True: # merge query parameters into URL instead of replacing (ref httpx#3364) - url_with_query_params = httpx.URL(url).copy_merge_params(params) + url_with_query_params = httpx.URL(api_path).copy_merge_params(params) - response_body = self.get(url_with_query_params, **kwargs) + response_body = self._request( + method, + url_with_query_params, + json=body_json, + **kwargs, + ) try: paginated_response = _PaginatedResponse.model_validate(response_body) @@ -155,4 +203,51 @@ def get_paginated( if next_cursor is None: break - params = {**params, "cursor": next_cursor} + match method: + case RequestMethod.GET: + params = {**params, "cursor": next_cursor} + case RequestMethod.POST: + if body_json is not None: + body_json["cursor"] = next_cursor + + def get_paginated( + self, + api_path: str | httpx.URL, + model: Type[T], + **kwargs: Any, + ) -> Iterator[T]: + """Retrieve objects from a paginated ENLYZE platform API endpoint via HTTP GET + + :param api_path: Relative URL path inside the ENLYZE platform API + :param model: API response model class derived from + :class:`~enlyze.api_client.models.PlatformApiModel` + + :returns: Instances of ``model`` retrieved from the ``api_path`` endpoint + + :raises: :exc:`~enlyze.errors.EnlyzeError` on invalid pagination schema + :raises: :exc:`~enlyze.errors.EnlyzeError` on invalid data schema + :raises: see :py:meth:`get` for more errors raised by this method + + """ + return self._request_paginated(RequestMethod.GET, api_path, model, **kwargs) + + def post_paginated( + self, + api_path: str | httpx.URL, + model: Type[T], + **kwargs: Any, + ) -> Iterator[T]: + """Retrieve objects from a paginated ENLYZE platform API endpoint via HTTP POST + + :param api_path: Relative URL path inside the ENLYZE platform API + :param model: API response model class derived from + :class:`~enlyze.api_client.models.PlatformApiModel` + + :returns: Instances of ``model`` retrieved from the ``api_path`` endpoint + + :raises: :exc:`~enlyze.errors.EnlyzeError` on invalid pagination schema + :raises: :exc:`~enlyze.errors.EnlyzeError` on invalid data schema + :raises: see :py:meth:`post` for more errors raised by this method + + """ + return self._request_paginated(RequestMethod.POST, api_path, model, **kwargs) diff --git a/src/enlyze/api_client/models.py b/src/enlyze/api_client/models.py index 26d769b..c605f25 100644 --- a/src/enlyze/api_client/models.py +++ b/src/enlyze/api_client/models.py @@ -24,14 +24,12 @@ def to_user_model(self, *args: Any, **kwargs: Any) -> Any: class Site(PlatformApiModel): uuid: UUID name: str - address: str def to_user_model(self) -> user_models.Site: """Convert into a :ref:`user model `""" return user_models.Site( uuid=self.uuid, - address=self.address, display_name=self.name, ) diff --git a/src/enlyze/client.py b/src/enlyze/client.py index f3b545d..dac0202 100644 --- a/src/enlyze/client.py +++ b/src/enlyze/client.py @@ -1,7 +1,7 @@ from collections import abc from datetime import datetime from functools import cache, reduce -from typing import Any, Iterator, Mapping, Optional, Sequence, Tuple, Union +from typing import Any, Iterator, Mapping, Optional, Sequence, Union from uuid import UUID import enlyze.api_client.models as platform_api_models @@ -10,7 +10,6 @@ from enlyze.constants import ( ENLYZE_BASE_URL, MAXIMUM_NUMBER_OF_VARIABLES_PER_TIMESERIES_REQUEST, - VARIABLE_UUID_AND_RESAMPLING_METHOD_SEPARATOR, ) from enlyze.errors import EnlyzeError, ResamplingValidationError from enlyze.iterable_tools import chunk @@ -45,13 +44,13 @@ def _get_timeseries_data_from_pages( return timeseries_data -def _get_variables_sequence_and_query_parameter_list( +def validate_resampling( variables: Union[ Sequence[user_models.Variable], Mapping[user_models.Variable, user_models.ResamplingMethod], ], resampling_interval: Optional[int], -) -> Tuple[Sequence[user_models.Variable], Sequence[str]]: +) -> None: if isinstance(variables, abc.Sequence) and resampling_interval is not None: raise ResamplingValidationError( "`variables` must be a mapping {variable: ResamplingMethod}" @@ -59,22 +58,10 @@ def _get_variables_sequence_and_query_parameter_list( if resampling_interval: validate_resampling_interval(resampling_interval) - variables_sequence = [] - variables_query_parameter_list = [] for variable, resampling_method in variables.items(): # type: ignore - variables_sequence.append(variable) - variables_query_parameter_list.append( - f"{variable.uuid}" - f"{VARIABLE_UUID_AND_RESAMPLING_METHOD_SEPARATOR}" - f"{resampling_method.value}" - ) - validate_resampling_method_for_data_type( resampling_method, variable.data_type ) - return variables_sequence, variables_query_parameter_list - - return variables, [str(v.uuid) for v in variables] # type: ignore class EnlyzeClient: @@ -183,21 +170,27 @@ def _get_paginated_timeseries( machine_uuid: str, start: datetime, end: datetime, - variables: Sequence[str], + variables: dict[UUID, Optional[user_models.ResamplingMethod]], resampling_interval: Optional[int], ) -> Iterator[platform_api_models.TimeseriesData]: - params: dict[str, Any] = { + request: dict[str, Any] = { "machine": machine_uuid, - "start_datetime": start.isoformat(), - "end_datetime": end.isoformat(), - "variables": ",".join(variables), + "start": start.isoformat(), + "end": end.isoformat(), + "resampling_interval": resampling_interval, + "variables": [ + { + "uuid": str(v), + "resampling_method": meth, + } + for v, meth in variables.items() + ], } - if resampling_interval: - params["resampling_interval"] = resampling_interval - - return self._platform_api_client.get_paginated( - "timeseries", platform_api_models.TimeseriesData, params=params + return self._platform_api_client.post_paginated( + "timeseries", + platform_api_models.TimeseriesData, + json=request, ) def _get_timeseries( @@ -210,19 +203,25 @@ def _get_timeseries( ], resampling_interval: Optional[int] = None, ) -> Optional[user_models.TimeseriesData]: - variables_sequence, variables_query_parameter_list = ( - _get_variables_sequence_and_query_parameter_list( - variables, resampling_interval - ) - ) + validate_resampling(variables, resampling_interval) + + variables_list = list(variables) start, end, machine_uuid = validate_timeseries_arguments( - start, end, variables_sequence + start, + end, + variables_list, + ) + + variable_uuids_with_resampling_method = ( + {v.uuid: meth for v, meth in variables.items()} + if isinstance(variables, dict) + else {v.uuid: None for v in variables} ) try: chunks = chunk( - variables_query_parameter_list, + list(variable_uuids_with_resampling_method.items()), MAXIMUM_NUMBER_OF_VARIABLES_PER_TIMESERIES_REQUEST, ) except ValueError as e: @@ -233,7 +232,7 @@ def _get_timeseries( machine_uuid=machine_uuid, start=start, end=end, - variables=chunk, + variables=dict(chunk), resampling_interval=resampling_interval, ) for chunk in chunks @@ -263,7 +262,7 @@ def _get_timeseries( return timeseries_data.to_user_model( # type: ignore start=start, end=end, - variables=variables_sequence, + variables=variables_list, ) def get_timeseries( diff --git a/src/enlyze/constants.py b/src/enlyze/constants.py index dea9986..f4b71fd 100644 --- a/src/enlyze/constants.py +++ b/src/enlyze/constants.py @@ -9,10 +9,6 @@ #: Reference: https://www.python-httpx.org/advanced/timeouts/ HTTPX_TIMEOUT = 30.0 -#: The separator to use when to separate the variable UUID and the resampling method -#: when querying timeseries data. -VARIABLE_UUID_AND_RESAMPLING_METHOD_SEPARATOR = "||" - #: The minimum allowed resampling interval when resampling timeseries data. MINIMUM_RESAMPLING_INTERVAL = 10 diff --git a/src/enlyze/models.py b/src/enlyze/models.py index 28b27ce..da78168 100644 --- a/src/enlyze/models.py +++ b/src/enlyze/models.py @@ -25,9 +25,6 @@ class Site: #: Display name of the site. display_name: str - #: Postal address of the site. Doesn't follow a strict format. - address: str - @dataclass(frozen=True) class Machine: diff --git a/tests/enlyze/test_client.py b/tests/enlyze/test_client.py index 62266ac..e98bbe3 100644 --- a/tests/enlyze/test_client.py +++ b/tests/enlyze/test_client.py @@ -227,7 +227,7 @@ def test_get_timeseries( with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: cursor = "next-1" - mock.get("timeseries", params=f"cursor={cursor}").mock( + mock.post("timeseries", json__cursor=cursor).mock( PaginatedPlatformApiResponse( data=platform_api_models.TimeseriesData( columns=["time", str(variable.uuid)], @@ -236,7 +236,7 @@ def test_get_timeseries( ) ) - mock.get("timeseries").mock( + mock.post("timeseries").mock( side_effect=lambda request: PaginatedPlatformApiResponse( data=platform_api_models.TimeseriesData( columns=["time", str(variable.uuid)], @@ -310,7 +310,7 @@ def test_get_timeseries_returns_none_on_empty_response( client = make_client() with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: - mock.get("timeseries").mock(PaginatedPlatformApiResponse(data=data)) + mock.post("timeseries").mock(PaginatedPlatformApiResponse(data=data)) if timeseries_call == "without_resampling": assert ( client.get_timeseries(start_datetime, end_datetime, [variable]) is None @@ -372,7 +372,7 @@ def test__get_timeseries_raises_on_mixed_response( ) with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: - mock.get("timeseries").mock( + mock.post("timeseries").mock( side_effect=[ PaginatedPlatformApiResponse( data=platform_api_models.TimeseriesData( @@ -443,7 +443,7 @@ def test_get_timeseries_raises_api_returned_no_timestamps( client = make_client() with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: - mock.get("timeseries").mock( + mock.post("timeseries").mock( PaginatedPlatformApiResponse( data=platform_api_models.TimeseriesData( columns=["something but not time"], @@ -516,7 +516,7 @@ def f(*args, **kwargs): monkeypatch.setattr("enlyze.client.reduce", f) with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: - mock.get("timeseries").mock( + mock.post("timeseries").mock( PaginatedPlatformApiResponse( data=platform_api_models.TimeseriesData( columns=["time", str(variable.uuid)],