Skip to content
117 changes: 106 additions & 11 deletions src/enlyze/api_client/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 "
Expand Down Expand Up @@ -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`
Expand All @@ -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)
Expand Down Expand Up @@ -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)
2 changes: 0 additions & 2 deletions src/enlyze/api_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <user_models>`"""

return user_models.Site(
uuid=self.uuid,
address=self.address,
display_name=self.name,
)

Expand Down
69 changes: 34 additions & 35 deletions src/enlyze/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -45,36 +44,24 @@ 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}"
)

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:
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 0 additions & 4 deletions src/enlyze/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 0 additions & 3 deletions src/enlyze/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading