From d3ba33358769759f2bda9a1c3fb5dc2fcde78305 Mon Sep 17 00:00:00 2001 From: Deniz Saner Date: Mon, 5 May 2025 17:49:31 +0200 Subject: [PATCH 01/10] Migrate to platform APIs Reapplies #47 --- LICENSE | 2 +- README.rst | 6 +- docs/api_client/client.rst | 7 + .../timeseries => api_client}/index.rst | 4 +- .../production_runs => api_client}/models.rst | 24 +- docs/api_clients/base.rst | 17 - docs/api_clients/index.rst | 9 - docs/api_clients/production_runs/client.rst | 9 - docs/api_clients/production_runs/index.rst | 8 - docs/api_clients/timeseries/client.rst | 11 - docs/api_clients/timeseries/models.rst | 30 -- docs/concepts.rst | 40 +-- docs/conf.py | 2 +- docs/examples.rst | 7 +- docs/index.rst | 2 +- docs/spelling_wordlist.txt | 1 + .../{api_clients => api_client}/__init__.py | 0 .../base.py => api_client/client.py} | 142 ++------ src/enlyze/api_client/models.py | 211 +++++++++++ .../api_clients/production_runs/client.py | 55 --- .../api_clients/production_runs/models.py | 119 ------- src/enlyze/api_clients/timeseries/__init__.py | 0 src/enlyze/api_clients/timeseries/client.py | 63 ---- src/enlyze/api_clients/timeseries/models.py | 115 ------ src/enlyze/auth.py | 2 +- src/enlyze/client.py | 106 +++--- src/enlyze/constants.py | 9 +- src/enlyze/errors.py | 8 +- src/enlyze/models.py | 18 +- .../enlyze/api_client}/__init__.py | 0 tests/enlyze/api_client/test_client.py | 298 ++++++++++++++++ .../timeseries => api_client}/test_models.py | 2 +- tests/enlyze/api_clients/__init__.py | 0 tests/enlyze/api_clients/conftest.py | 24 -- .../api_clients/production_runs/__init__.py | 0 .../production_runs/test_client.py | 117 ------ tests/enlyze/api_clients/test_base.py | 333 ------------------ .../enlyze/api_clients/timeseries/__init__.py | 0 .../api_clients/timeseries/test_client.py | 108 ------ tests/enlyze/test_auth.py | 2 +- tests/enlyze/test_client.py | 173 ++++----- 41 files changed, 748 insertions(+), 1336 deletions(-) create mode 100644 docs/api_client/client.rst rename docs/{api_clients/timeseries => api_client}/index.rst (64%) rename docs/{api_clients/production_runs => api_client}/models.rst (58%) delete mode 100644 docs/api_clients/base.rst delete mode 100644 docs/api_clients/index.rst delete mode 100644 docs/api_clients/production_runs/client.rst delete mode 100644 docs/api_clients/production_runs/index.rst delete mode 100644 docs/api_clients/timeseries/client.rst delete mode 100644 docs/api_clients/timeseries/models.rst rename src/enlyze/{api_clients => api_client}/__init__.py (100%) rename src/enlyze/{api_clients/base.py => api_client/client.py} (50%) create mode 100644 src/enlyze/api_client/models.py delete mode 100644 src/enlyze/api_clients/production_runs/client.py delete mode 100644 src/enlyze/api_clients/production_runs/models.py delete mode 100644 src/enlyze/api_clients/timeseries/__init__.py delete mode 100644 src/enlyze/api_clients/timeseries/client.py delete mode 100644 src/enlyze/api_clients/timeseries/models.py rename {src/enlyze/api_clients/production_runs => tests/enlyze/api_client}/__init__.py (100%) create mode 100644 tests/enlyze/api_client/test_client.py rename tests/enlyze/{api_clients/timeseries => api_client}/test_models.py (98%) delete mode 100644 tests/enlyze/api_clients/__init__.py delete mode 100644 tests/enlyze/api_clients/conftest.py delete mode 100644 tests/enlyze/api_clients/production_runs/__init__.py delete mode 100644 tests/enlyze/api_clients/production_runs/test_client.py delete mode 100644 tests/enlyze/api_clients/test_base.py delete mode 100644 tests/enlyze/api_clients/timeseries/__init__.py delete mode 100644 tests/enlyze/api_clients/timeseries/test_client.py diff --git a/LICENSE b/LICENSE index e346fa7..02527cf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2024 ENLYZE GmbH +Copyright 2025 ENLYZE GmbH Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software diff --git a/README.rst b/README.rst index 2020fe8..673e902 100644 --- a/README.rst +++ b/README.rst @@ -65,8 +65,8 @@ so:* $ export PYENCHANT_LIBRARY_PATH=/opt/homebrew/lib/libenchant-2.dylib - Examples --------------------------------- -You can find examples of how to use the Python SDK here: `Notebooks `_ +-------- +You can find examples of how to use the Python SDK here: `Notebooks +`_ diff --git a/docs/api_client/client.rst b/docs/api_client/client.rst new file mode 100644 index 0000000..0611f96 --- /dev/null +++ b/docs/api_client/client.rst @@ -0,0 +1,7 @@ +Platform API Client +=================== + +.. currentmodule:: enlyze.api_client.client + +.. autoclass:: PlatformApiClient() + :members: diff --git a/docs/api_clients/timeseries/index.rst b/docs/api_client/index.rst similarity index 64% rename from docs/api_clients/timeseries/index.rst rename to docs/api_client/index.rst index 33f642b..697bddb 100644 --- a/docs/api_clients/timeseries/index.rst +++ b/docs/api_client/index.rst @@ -1,5 +1,5 @@ -Timeseries API -============== +API Client +========== .. toctree:: :maxdepth: 1 diff --git a/docs/api_clients/production_runs/models.rst b/docs/api_client/models.rst similarity index 58% rename from docs/api_clients/production_runs/models.rst rename to docs/api_client/models.rst index 03581d4..d4df92e 100644 --- a/docs/api_clients/production_runs/models.rst +++ b/docs/api_client/models.rst @@ -1,11 +1,11 @@ Models ====== -.. currentmodule:: enlyze.api_clients.production_runs.models +.. currentmodule:: enlyze.api_client.models -.. autoclass:: ProductionRunsApiModel() +.. autoclass:: PlatformApiModel() -.. autoclass:: ProductionRun() +.. autoclass:: Site() :members: :undoc-members: :exclude-members: model_config, model_fields @@ -17,6 +17,24 @@ Models :exclude-members: model_config, model_fields :show-inheritance: +.. autoclass:: Variable() + :members: + :undoc-members: + :exclude-members: model_config, model_fields + :show-inheritance: + +.. autoclass:: TimeseriesData() + :members: + :undoc-members: + :exclude-members: model_config, model_fields + :show-inheritance: + +.. autoclass:: ProductionRun() + :members: + :undoc-members: + :exclude-members: model_config, model_fields + :show-inheritance: + .. autoclass:: Quantity() :members: :undoc-members: diff --git a/docs/api_clients/base.rst b/docs/api_clients/base.rst deleted file mode 100644 index 1f7ef06..0000000 --- a/docs/api_clients/base.rst +++ /dev/null @@ -1,17 +0,0 @@ -Base Client -=========== - -.. currentmodule:: enlyze.api_clients.base - -.. autoclass:: M - -.. autoclass:: R - -.. autoclass:: ApiBaseModel - -.. autoclass:: PaginatedResponseBaseModel - -.. autoclass:: ApiBaseClient - :members: - :private-members: - :undoc-members: diff --git a/docs/api_clients/index.rst b/docs/api_clients/index.rst deleted file mode 100644 index 91ec8e1..0000000 --- a/docs/api_clients/index.rst +++ /dev/null @@ -1,9 +0,0 @@ -API Clients -=========== - -.. toctree:: - :maxdepth: 1 - - base - timeseries/index - production_runs/index diff --git a/docs/api_clients/production_runs/client.rst b/docs/api_clients/production_runs/client.rst deleted file mode 100644 index 2c5f92f..0000000 --- a/docs/api_clients/production_runs/client.rst +++ /dev/null @@ -1,9 +0,0 @@ -Production Runs API Client -========================== - -.. currentmodule:: enlyze.api_clients.production_runs.client - -.. autoclass:: _PaginatedResponse - -.. autoclass:: ProductionRunsApiClient() - :members: diff --git a/docs/api_clients/production_runs/index.rst b/docs/api_clients/production_runs/index.rst deleted file mode 100644 index 34a3568..0000000 --- a/docs/api_clients/production_runs/index.rst +++ /dev/null @@ -1,8 +0,0 @@ -Production Runs API -=================== - -.. toctree:: - :maxdepth: 1 - - client - models diff --git a/docs/api_clients/timeseries/client.rst b/docs/api_clients/timeseries/client.rst deleted file mode 100644 index fb84a40..0000000 --- a/docs/api_clients/timeseries/client.rst +++ /dev/null @@ -1,11 +0,0 @@ -Timeseries API Client -===================== - -.. currentmodule:: enlyze.api_clients.timeseries.client - -.. autoclass:: _PaginatedResponse() - :members: - :exclude-members: model_config, model_fields - -.. autoclass:: TimeseriesApiClient() - :members: get, get_paginated diff --git a/docs/api_clients/timeseries/models.rst b/docs/api_clients/timeseries/models.rst deleted file mode 100644 index dd03852..0000000 --- a/docs/api_clients/timeseries/models.rst +++ /dev/null @@ -1,30 +0,0 @@ -Models -====== - -.. currentmodule:: enlyze.api_clients.timeseries.models - -.. autoclass:: TimeseriesApiModel() - -.. autoclass:: Site() - :members: - :undoc-members: - :exclude-members: model_config, model_fields - :show-inheritance: - -.. autoclass:: Machine() - :members: - :undoc-members: - :exclude-members: model_config, model_fields - :show-inheritance: - -.. autoclass:: Variable() - :members: - :undoc-members: - :exclude-members: model_config, model_fields - :show-inheritance: - -.. autoclass:: TimeseriesData() - :members: - :undoc-members: - :exclude-members: model_config, model_fields - :show-inheritance: diff --git a/docs/concepts.rst b/docs/concepts.rst index 5e2c6c4..71503ea 100644 --- a/docs/concepts.rst +++ b/docs/concepts.rst @@ -16,7 +16,7 @@ a name and an address, which makes it easy to identify for humans. .. _machine: Machine ---------- +------- A *machine* refers to a machine that your organization uses to produce goods. For example, a CNC-milling center, a blown film extrusion line or an injection molding @@ -30,8 +30,8 @@ Variable -------- A *variable* represents a process measure of one :ref:`machine ` of which -timeseries data is captured and stored in the ENLYZE platform. One machine may have -many variables, whereas one variable is only associated with one machine. +timeseries data is captured and stored in the ENLYZE platform. One machine may have many +variables, whereas one variable is only associated with one machine. .. _production_order: @@ -45,8 +45,8 @@ MES and then synchronized into the ENLYZE platform. They are referenced by an id which oftentimes is a short combination of numbers and/or characters, like FA23000123. In the ENLYZE platform, a production order always encompasses the production of one -single :ref:`product ` on one single :ref:`machine ` within one -or more :ref:`production runs `. +single :ref:`product ` on one single :ref:`machine ` within one or +more :ref:`production runs `. .. _production_run: @@ -54,29 +54,29 @@ Production Run -------------- A *production run* is a time frame within which a machine was producing a :ref:`product -` on a :ref:`machine ` in order to complete a :ref:`production -order `. A production run always has a beginning and, if it's not -still running, it also has an end. +` on a :ref:`machine ` in order to complete a :ref:`production order +`. A production run always has a beginning and, if it's not still +running, it also has an end. Usually, the operator of the machine uses an interface to log the time when a certain -production order has been worked on. For instance, this could be the machine's HMI or -a tablet computer next to it. In German, this is often referred to as *Betriebsdatenerfassung* (BDE). -It is common, that a production order is not completed in one go, but is interrupted -several times for very different reasons, like a breakdown of the machine or a -public holiday. These interruptions lead to the creation of multiple production runs -for a single production order. +production order has been worked on. For instance, this could be the machine's HMI or a +tablet computer next to it. In German, this is often referred to as +*Betriebsdatenerfassung* (BDE). It is common, that a production order is not completed +in one go, but is interrupted several times for very different reasons, like a breakdown +of the machine or a public holiday. These interruptions lead to the creation of multiple +production runs for a single production order. .. _product: Product ------- -A *product* is the output of the production process which is executed by a -:ref:`machine `, driven by a :ref:`production order `. In -the real world, a machine might have some additional outputs, but only the main -output (the product) is modeled in the ENLYZE platform. Similarly to the production order, -a product is referenced by an identifier originating from a customer's system, that gets -synchronized into the ENLYZE platform. +A *product* is the output of the production process which is executed by a :ref:`machine +`, driven by a :ref:`production order `. In the real world, a +machine might have some additional outputs, but only the main output (the product) is +modeled in the ENLYZE platform. Similarly to the production order, a product is +referenced by an identifier originating from a customer's system, that gets synchronized +into the ENLYZE platform. During the integration into the ENLYZE platform, the product identifier is chosen in such a way that :ref:`production runs ` of the same product are diff --git a/docs/conf.py b/docs/conf.py index 7d9fae9..f47a84b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,7 @@ project = "enlyze" -copyright = "2024, ENLYZE GmbH" +copyright = "2025, ENLYZE GmbH" author = "ENLYZE GmbH" # -- General configuration --------------------------------------------------- diff --git a/docs/examples.rst b/docs/examples.rst index 036b345..11b1a2e 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,7 +1,10 @@ Examples ======== -In our examples section, you can find Jupyter Notebooks and other material to help you get started: + +In our examples section, you can find Jupyter Notebooks and other material to help you +get started: `Notebooks `_: -* `Introduction to the ENLYZE Python SDK `_ +- `Introduction to the ENLYZE Python SDK + `_ diff --git a/docs/index.rst b/docs/index.rst index 5d9247a..e7e605a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -20,4 +20,4 @@ User's Guide models errors constants - api_clients/index + api_client/index diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index d97518f..35eef13 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -11,6 +11,7 @@ enlyze entrypoint iterable noqa +Pydantic quickstart resample resampled diff --git a/src/enlyze/api_clients/__init__.py b/src/enlyze/api_client/__init__.py similarity index 100% rename from src/enlyze/api_clients/__init__.py rename to src/enlyze/api_client/__init__.py diff --git a/src/enlyze/api_clients/base.py b/src/enlyze/api_client/client.py similarity index 50% rename from src/enlyze/api_clients/base.py rename to src/enlyze/api_client/client.py index ca3502c..a382293 100644 --- a/src/enlyze/api_clients/base.py +++ b/src/enlyze/api_client/client.py @@ -1,18 +1,20 @@ import json -from abc import ABC, abstractmethod -from collections.abc import Iterator from functools import cache from http import HTTPStatus -from typing import Any, Generic, TypeVar +from typing import Any, Iterator, Type, TypeVar import httpx from pydantic import BaseModel, ValidationError from enlyze._version import VERSION from enlyze.auth import TokenAuth -from enlyze.constants import HTTPX_TIMEOUT, USER_AGENT +from enlyze.constants import HTTPX_TIMEOUT, PLATFORM_API_SUB_PATH, USER_AGENT from enlyze.errors import EnlyzeError, InvalidTokenError +from .models import PlatformApiModel + +T = TypeVar("T", bound=PlatformApiModel) + USER_AGENT_NAME_VERSION_SEPARATOR = "/" @@ -23,42 +25,24 @@ def _construct_user_agent( return f"{user_agent}{USER_AGENT_NAME_VERSION_SEPARATOR}{version}" -class ApiBaseModel(BaseModel): - """Base class for ENLYZE platform API object models using pydantic - - All objects received from ENLYZE platform APIs are passed into models that derive - from this class and thus use pydantic for schema definition and validation. - - """ - - -class PaginatedResponseBaseModel(BaseModel): - """Base class for paginated ENLYZE platform API responses using pydantic.""" - - data: Any - - -#: TypeVar("M", bound=ApiBaseModel): Type variable serving as a parameter -# for API response model classes. -M = TypeVar("M", bound=ApiBaseModel) +class _Metadata(BaseModel): + next_cursor: str | None -#: TypeVar("R", bound=PaginatedResponseBaseModel) Type variable serving as a parameter -# for paginated response models. -R = TypeVar("R", bound=PaginatedResponseBaseModel) +class _PaginatedResponse(BaseModel): + metadata: _Metadata + data: list[dict[str, Any]] | dict[str, Any] -class ApiBaseClient(ABC, Generic[R]): - """Client base class encapsulating all interaction with all ENLYZE platform APIs. +class PlatformApiClient: + """API Client class encapsulating all interaction with the ENLYZE platform :param token: API token for the ENLYZE platform :param base_url: Base URL of the ENLYZE platform - :param timeout: Global timeout for HTTP requests sent to the ENLYZE platform APIs + :param timeout: Global timeout for all HTTP requests sent to the ENLYZE platform """ - PaginatedResponseModel: type[R] - def __init__( self, *, @@ -68,7 +52,7 @@ def __init__( ): self._client = httpx.Client( auth=TokenAuth(token), - base_url=httpx.URL(base_url), + base_url=httpx.URL(base_url).join(PLATFORM_API_SUB_PATH), timeout=timeout, headers={"user-agent": _construct_user_agent()}, ) @@ -83,16 +67,13 @@ def get(self, api_path: str | httpx.URL, **kwargs: Any) -> Any: :param api_path: Relative URL path inside the API name space (or a full URL) - :raises: :exc:`~enlyze.errors.EnlyzeError` on request failure + :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 - :returns: JSON payload of the response as Python object - """ - try: response = self._client.get(api_path, **kwargs) except Exception as e: @@ -123,75 +104,22 @@ def get(self, api_path: str | httpx.URL, **kwargs: Any) -> Any: f"(GET {self._full_url(api_path)})", ) from e - def _transform_paginated_response_data(self, data: Any) -> Any: - """Transform paginated response data. Returns ``data`` by default. - - :param data: Response data from a paginated response - - :returns: An iterable of transformed data - - """ - return data - - @abstractmethod - def _has_more(self, paginated_response: R) -> bool: - """Indicates there is more data to fetch from the server. - - :param paginated_response: A paginated response model deriving from - :class:`PaginatedResponseBaseModel`. - - """ - - @abstractmethod - def _next_page_call_args( - self, - *, - url: str | httpx.URL, - params: dict[str, Any], - paginated_response: R, - **kwargs: Any, - ) -> tuple[str | httpx.URL, dict[str, Any], dict[str, Any]]: - r"""Compute call arguments for the next page. - - :param url: The URL used to fetch the current page - :param params: URL query parameters of the current page - :param paginated_response: A paginated response model deriving from - :class:`~enlyze.api_clients.base.PaginatedResponseBaseModel` - :param \**kwargs: Keyword arguments passed into - :py:meth:`~enlyze.api_clients.base.ApiBaseClient.get_paginated` - - :returns: A tuple of comprised of the URL, query parameters and keyword - arguments to fetch the next page + 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` - def get_paginated( - self, api_path: str | httpx.URL, model: type[M], **kwargs: Any - ) -> Iterator[M]: - """Retrieve objects from a paginated ENLYZE platform API endpoint via HTTP GET. - - To add pagination capabilities to an API client deriving from this class, two - abstract methods need to be implemented, - :py:meth:`~enlyze.api_clients.base.ApiBaseClient._has_more` and - :py:meth:`~enlyze.api_clients.base.ApiBaseClient._next_page_call_args`. - Optionally, API clients may transform page data by overriding - :py:meth:`~enlyze.api_clients.base.ApiBaseClient._transform_paginated_response_data`, - which by default returns the unmodified page data. - - :param api_path: Relative URL path inside the API name space - :param model: API response model class deriving from - :class:`~enlyze.api_clients.base.ApiBaseModel` + :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 - :returns: Instances of ``model`` retrieved from the ``api_path`` endpoint - """ - url = api_path params = kwargs.pop("params", {}) @@ -200,20 +128,19 @@ def get_paginated( url_with_query_params = httpx.URL(url).copy_merge_params(params) response_body = self.get(url_with_query_params, **kwargs) + try: - paginated_response = self.PaginatedResponseModel.model_validate( - response_body - ) + paginated_response = _PaginatedResponse.model_validate(response_body) except ValidationError as e: raise EnlyzeError( - f"Paginated response expected (GET {self._full_url(url)})" + f"Paginated response expected (GET {self._full_url(api_path)})" ) from e page_data = paginated_response.data if not page_data: break - page_data = self._transform_paginated_response_data(page_data) + page_data = page_data if isinstance(page_data, list) else [page_data] for elem in page_data: try: @@ -223,12 +150,9 @@ def get_paginated( f"ENLYZE platform API returned an unparsable {model.__name__} " f"object (GET {self._full_url(api_path)})" ) from e - if not self._has_more(paginated_response): + + next_cursor = paginated_response.metadata.next_cursor + if next_cursor is None: break - url, params, kwargs = self._next_page_call_args( - url=url, - params=params, - paginated_response=paginated_response, - **kwargs, - ) + params = {**params, "cursor": next_cursor} diff --git a/src/enlyze/api_client/models.py b/src/enlyze/api_client/models.py new file mode 100644 index 0000000..26d769b --- /dev/null +++ b/src/enlyze/api_client/models.py @@ -0,0 +1,211 @@ +from abc import abstractmethod +from datetime import date, datetime, timedelta +from typing import Any, Optional, Sequence +from uuid import UUID + +from pydantic import BaseModel + +import enlyze.models as user_models + + +class PlatformApiModel(BaseModel): + """Base class for ENLYZE platform API object models using Pydantic + + All objects received from the ENLYZE platform API are passed into models that derive + from this class and thus use Pydantic for schema definition and validation. + + """ + + @abstractmethod + def to_user_model(self, *args: Any, **kwargs: Any) -> Any: + """Convert to a model that will be returned to the user.""" + + +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, + ) + + +class Machine(PlatformApiModel): + name: str + uuid: UUID + genesis_date: date + site: UUID + + def to_user_model(self, site: user_models.Site) -> user_models.Machine: + """Convert into a :ref:`user model `""" + + return user_models.Machine( + uuid=self.uuid, + display_name=self.name, + genesis_date=self.genesis_date, + site=site, + ) + + +class Variable(PlatformApiModel): + uuid: UUID + display_name: Optional[str] + unit: Optional[str] + data_type: user_models.VariableDataType + + def to_user_model(self, machine: user_models.Machine) -> user_models.Variable: + """Convert into a :ref:`user model `.""" + + return user_models.Variable( + uuid=self.uuid, + display_name=self.display_name, + unit=self.unit, + data_type=self.data_type, + machine=machine, + ) + + +class TimeseriesData(PlatformApiModel): + columns: list[str] + records: list[Any] + + def extend(self, other: "TimeseriesData") -> None: + """Add records from ``other`` after the existing records.""" + self.records.extend(other.records) + + def merge(self, other: "TimeseriesData") -> "TimeseriesData": + """Merge records from ``other`` into the existing records.""" + slen, olen = len(self.records), len(other.records) + if olen < slen: + raise ValueError( + "Cannot merge. Attempted to merge" + f" an instance with {olen} records into an instance with {slen}" + " records. The instance to merge must have a number" + " of records greater than or equal to the number of records of" + " the instance you're trying to merge into." + ) + + self.columns.extend(other.columns[1:]) + + for s, o in zip(self.records, other.records[:slen]): + if s[0] != o[0]: + raise ValueError( + "Cannot merge. Attempted to merge records " + f"with mismatched timestamps {s[0]}, {o[0]}" + ) + + s.extend(o[1:]) + + return self + + def to_user_model( + self, + start: datetime, + end: datetime, + variables: Sequence[user_models.Variable], + ) -> user_models.TimeseriesData: + return user_models.TimeseriesData( + start=start, + end=end, + variables=variables, + _columns=self.columns, + _records=self.records, + ) + + +class OEEComponent(PlatformApiModel): + score: float + time_loss: int + + def to_user_model(self) -> user_models.OEEComponent: + """Convert into a :ref:`user model `""" + + return user_models.OEEComponent( + score=self.score, + time_loss=timedelta(seconds=self.time_loss), + ) + + +class Product(PlatformApiModel): + code: str + name: Optional[str] + + def to_user_model(self) -> user_models.Product: + """Convert into a :ref:`user model `""" + + return user_models.Product( + code=self.code, + name=self.name, + ) + + +class Quantity(PlatformApiModel): + unit: str | None + value: float + + def to_user_model(self) -> user_models.Quantity: + """Convert into a :ref:`user model `""" + + return user_models.Quantity( + unit=self.unit, + value=self.value, + ) + + +class ProductionRun(PlatformApiModel): + uuid: UUID + machine: UUID + average_throughput: Optional[float] + production_order: str + product: Product + start: datetime + end: Optional[datetime] + quantity_total: Optional[Quantity] + quantity_scrap: Optional[Quantity] + quantity_yield: Optional[Quantity] + availability: Optional[OEEComponent] + performance: Optional[OEEComponent] + quality: Optional[OEEComponent] + productivity: Optional[OEEComponent] + + def to_user_model( + self, machines_by_uuid: dict[UUID, user_models.Machine] + ) -> user_models.ProductionRun: + """Convert into a :ref:`user model `""" + + quantity_total = ( + self.quantity_total.to_user_model() if self.quantity_total else None + ) + quantity_scrap = ( + self.quantity_scrap.to_user_model() if self.quantity_scrap else None + ) + quantity_yield = ( + self.quantity_yield.to_user_model() if self.quantity_yield else None + ) + availability = self.availability.to_user_model() if self.availability else None + performance = self.performance.to_user_model() if self.performance else None + quality = self.quality.to_user_model() if self.quality else None + productivity = self.productivity.to_user_model() if self.productivity else None + + return user_models.ProductionRun( + uuid=self.uuid, + machine=machines_by_uuid[self.machine], + average_throughput=self.average_throughput, + production_order=self.production_order, + product=self.product.to_user_model(), + start=self.start, + end=self.end, + quantity_total=quantity_total, + quantity_scrap=quantity_scrap, + quantity_yield=quantity_yield, + availability=availability, + performance=performance, + quality=quality, + productivity=productivity, + ) diff --git a/src/enlyze/api_clients/production_runs/client.py b/src/enlyze/api_clients/production_runs/client.py deleted file mode 100644 index 944cf4a..0000000 --- a/src/enlyze/api_clients/production_runs/client.py +++ /dev/null @@ -1,55 +0,0 @@ -from typing import Any - -import httpx -from pydantic import BaseModel - -from enlyze.api_clients.base import ApiBaseClient, PaginatedResponseBaseModel -from enlyze.constants import PRODUCTION_RUNS_API_SUB_PATH - - -class _Metadata(BaseModel): - next_cursor: int | None - has_more: bool - - -class _PaginatedResponse(PaginatedResponseBaseModel): - metadata: _Metadata - data: list[dict[str, Any]] - - -class ProductionRunsApiClient(ApiBaseClient[_PaginatedResponse]): - """Client class encapsulating all interaction with the Production Runs API - - :param token: API token for the ENLYZE platform - :param base_url: Base URL of the ENLYZE platform - :param timeout: Global timeout for all HTTP requests sent to the Production Runs API - - """ - - PaginatedResponseModel = _PaginatedResponse - - def __init__( - self, - *, - token: str, - base_url: str | httpx.URL, - **kwargs: Any, - ): - super().__init__( - token=token, - base_url=httpx.URL(base_url).join(PRODUCTION_RUNS_API_SUB_PATH), - **kwargs, - ) - - def _has_more(self, paginated_response: _PaginatedResponse) -> bool: - return paginated_response.metadata.has_more - - def _next_page_call_args( - self, - url: str | httpx.URL, - params: dict[str, Any], - paginated_response: _PaginatedResponse, - **kwargs: Any, - ) -> tuple[str | httpx.URL, dict[str, Any], dict[str, Any]]: - next_params = {**params, "cursor": paginated_response.metadata.next_cursor} - return (url, next_params, kwargs) diff --git a/src/enlyze/api_clients/production_runs/models.py b/src/enlyze/api_clients/production_runs/models.py deleted file mode 100644 index d52fe06..0000000 --- a/src/enlyze/api_clients/production_runs/models.py +++ /dev/null @@ -1,119 +0,0 @@ -from abc import abstractmethod -from datetime import datetime, timedelta -from typing import Any, Optional -from uuid import UUID - -from pydantic import Field - -import enlyze.models as user_models -from enlyze.api_clients.base import ApiBaseModel - - -class ProductionRunsApiModel(ApiBaseModel): - """Base class for Production Runs API object models using pydantic - - All objects received from the Production Runs API are passed into models that derive - from this class and thus use pydantic for schema definition and validation. - - """ - - @abstractmethod - def to_user_model(self, *args: Any, **kwargs: Any) -> Any: - """Convert to a model that will be returned to the user.""" - - -class OEEComponent(ProductionRunsApiModel): - score: float - time_loss: int - - def to_user_model(self) -> user_models.OEEComponent: - """Convert into a :ref:`user model `""" - - return user_models.OEEComponent( - score=self.score, - time_loss=timedelta(seconds=self.time_loss), - ) - - -class Product(ProductionRunsApiModel): - code: str - name: Optional[str] - - def to_user_model(self) -> user_models.Product: - """Convert into a :ref:`user model `""" - - return user_models.Product( - code=self.code, - name=self.name, - ) - - -class Quantity(ProductionRunsApiModel): - unit: str | None - value: float - - def to_user_model(self) -> user_models.Quantity: - """Convert into a :ref:`user model `""" - - return user_models.Quantity( - unit=self.unit, - value=self.value, - ) - - -class Machine(ApiBaseModel): - name: str - uuid: UUID - - -class ProductionRun(ProductionRunsApiModel): - uuid: UUID - machine: Machine = Field(alias="appliance") - average_throughput: Optional[float] - production_order: str - product: Product - start: datetime - end: Optional[datetime] - quantity_total: Optional[Quantity] - quantity_scrap: Optional[Quantity] - quantity_yield: Optional[Quantity] - availability: Optional[OEEComponent] - performance: Optional[OEEComponent] - quality: Optional[OEEComponent] - productivity: Optional[OEEComponent] - - def to_user_model( - self, machines_by_uuid: dict[UUID, user_models.Machine] - ) -> user_models.ProductionRun: - """Convert into a :ref:`user model `""" - - quantity_total = ( - self.quantity_total.to_user_model() if self.quantity_total else None - ) - quantity_scrap = ( - self.quantity_scrap.to_user_model() if self.quantity_scrap else None - ) - quantity_yield = ( - self.quantity_yield.to_user_model() if self.quantity_yield else None - ) - availability = self.availability.to_user_model() if self.availability else None - performance = self.performance.to_user_model() if self.performance else None - quality = self.quality.to_user_model() if self.quality else None - productivity = self.productivity.to_user_model() if self.productivity else None - - return user_models.ProductionRun( - uuid=self.uuid, - machine=machines_by_uuid[self.machine.uuid], - average_throughput=self.average_throughput, - production_order=self.production_order, - product=self.product.to_user_model(), - start=self.start, - end=self.end, - quantity_total=quantity_total, - quantity_scrap=quantity_scrap, - quantity_yield=quantity_yield, - availability=availability, - performance=performance, - quality=quality, - productivity=productivity, - ) diff --git a/src/enlyze/api_clients/timeseries/__init__.py b/src/enlyze/api_clients/timeseries/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/enlyze/api_clients/timeseries/client.py b/src/enlyze/api_clients/timeseries/client.py deleted file mode 100644 index c8cf83e..0000000 --- a/src/enlyze/api_clients/timeseries/client.py +++ /dev/null @@ -1,63 +0,0 @@ -from typing import Any, Tuple - -import httpx -from pydantic import AnyUrl - -from enlyze.api_clients.base import ApiBaseClient, PaginatedResponseBaseModel -from enlyze.constants import TIMESERIES_API_SUB_PATH - - -class _PaginatedResponse(PaginatedResponseBaseModel): - next: AnyUrl | None - data: list[Any] | dict[str, Any] - - -class TimeseriesApiClient(ApiBaseClient[_PaginatedResponse]): - """Client class encapsulating all interaction with the Timeseries API - - :param token: API token for the ENLYZE platform - :param base_url: Base URL of the ENLYZE platform - :param timeout: Global timeout for all HTTP requests sent to the Timeseries API - - """ - - PaginatedResponseModel = _PaginatedResponse - - def __init__( - self, - *, - token: str, - base_url: str | httpx.URL, - **kwargs: Any, - ): - super().__init__( - token=token, - base_url=httpx.URL(base_url).join(TIMESERIES_API_SUB_PATH), - **kwargs, - ) - - def _transform_paginated_response_data( - self, paginated_response_data: list[Any] | dict[str, Any] - ) -> list[dict[str, Any]]: - # The timeseries endpoint's response data field is a mapping. - # Because get_paginated assumes the ``data`` field to be a list, - # we wrap it into a list. - return ( - paginated_response_data - if isinstance(paginated_response_data, list) - else [paginated_response_data] - ) - - def _has_more(self, paginated_response: _PaginatedResponse) -> bool: - return paginated_response.next is not None - - def _next_page_call_args( - self, - *, - url: str | httpx.URL, - params: dict[str, Any], - paginated_response: _PaginatedResponse, - **kwargs: Any, - ) -> Tuple[str | httpx.URL, dict[str, Any], dict[str, Any]]: - next_url = str(paginated_response.next) - return (next_url, params, kwargs) diff --git a/src/enlyze/api_clients/timeseries/models.py b/src/enlyze/api_clients/timeseries/models.py deleted file mode 100644 index 632657f..0000000 --- a/src/enlyze/api_clients/timeseries/models.py +++ /dev/null @@ -1,115 +0,0 @@ -from datetime import date, datetime -from typing import Any, Optional, Sequence -from uuid import UUID - -import enlyze.models as user_models -from enlyze.api_clients.base import ApiBaseModel - - -class TimeseriesApiModel(ApiBaseModel): - """Base class for Timeseries API object models using pydantic - - All objects received from the Timeseries API are passed into models that derive from - this class and thus use pydantic for schema definition and validation. - - """ - - pass - - -class Site(TimeseriesApiModel): - id: int - name: str - address: str - - def to_user_model(self) -> user_models.Site: - """Convert into a :ref:`user model `""" - - return user_models.Site( - _id=self.id, - address=self.address, - display_name=self.name, - ) - - -class Machine(TimeseriesApiModel): - uuid: UUID - name: str - genesis_date: date - site: int - - def to_user_model(self, site: user_models.Site) -> user_models.Machine: - """Convert into a :ref:`user model `""" - - return user_models.Machine( - uuid=self.uuid, - display_name=self.name, - genesis_date=self.genesis_date, - site=site, - ) - - -class Variable(TimeseriesApiModel): - uuid: UUID - display_name: Optional[str] - unit: Optional[str] - data_type: user_models.VariableDataType - - def to_user_model(self, machine: user_models.Machine) -> user_models.Variable: - """Convert into a :ref:`user model `.""" - - return user_models.Variable( - uuid=self.uuid, - display_name=self.display_name, - unit=self.unit, - data_type=self.data_type, - machine=machine, - ) - - -class TimeseriesData(TimeseriesApiModel): - columns: list[str] - records: list[Any] - - def extend(self, other: "TimeseriesData") -> None: - """Add records from ``other`` after the existing records.""" - self.records.extend(other.records) - - def merge(self, other: "TimeseriesData") -> "TimeseriesData": - """Merge records from ``other`` into the existing records.""" - slen, olen = len(self.records), len(other.records) - if olen < slen: - raise ValueError( - "Cannot merge. Attempted to merge" - f" an instance with {olen} records into an instance with {slen}" - " records. The instance to merge must have a number" - " of records greater than or equal to the number of records of" - " the instance you're trying to merge into." - ) - - self.columns.extend(other.columns[1:]) - - for s, o in zip(self.records, other.records[:slen]): - if s[0] != o[0]: - raise ValueError( - "Cannot merge. Attempted to merge records " - f"with mismatched timestamps {s[0]}, {o[0]}" - ) - - s.extend(o[1:]) - - return self - - def to_user_model( - self, - start: datetime, - end: datetime, - variables: Sequence[user_models.Variable], - ) -> user_models.TimeseriesData: - return user_models.TimeseriesData( - start=start, - end=end, - variables=variables, - _columns=self.columns, - _records=self.records, - ) diff --git a/src/enlyze/auth.py b/src/enlyze/auth.py index c3eda89..4f09e28 100644 --- a/src/enlyze/auth.py +++ b/src/enlyze/auth.py @@ -19,7 +19,7 @@ def __init__(self, token: str): if not token: raise InvalidTokenError("Token must not be empty") - self._auth_header = f"Token {token}" + self._auth_header = f"Bearer {token}" def auth_flow(self, request: Request) -> Generator[Request, Response, None]: """Inject token into authorization header""" diff --git a/src/enlyze/client.py b/src/enlyze/client.py index a29f6bf..f3b545d 100644 --- a/src/enlyze/client.py +++ b/src/enlyze/client.py @@ -4,11 +4,9 @@ from typing import Any, Iterator, Mapping, Optional, Sequence, Tuple, Union from uuid import UUID -import enlyze.api_clients.timeseries.models as timeseries_api_models +import enlyze.api_client.models as platform_api_models import enlyze.models as user_models -from enlyze.api_clients.production_runs.client import ProductionRunsApiClient -from enlyze.api_clients.production_runs.models import ProductionRun -from enlyze.api_clients.timeseries.client import TimeseriesApiClient +from enlyze.api_client.client import PlatformApiClient from enlyze.constants import ( ENLYZE_BASE_URL, MAXIMUM_NUMBER_OF_VARIABLES_PER_TIMESERIES_REQUEST, @@ -28,8 +26,8 @@ def _get_timeseries_data_from_pages( - pages: Iterator[timeseries_api_models.TimeseriesData], -) -> Optional[timeseries_api_models.TimeseriesData]: + pages: Iterator[platform_api_models.TimeseriesData], +) -> Optional[platform_api_models.TimeseriesData]: try: timeseries_data = next(pages) except StopIteration: @@ -39,7 +37,7 @@ def _get_timeseries_data_from_pages( return None if "time" not in timeseries_data.columns: - raise EnlyzeError("Timeseries API didn't return timestamps") + raise EnlyzeError("Platform API didn't return timestamps") for page in pages: timeseries_data.extend(page) @@ -90,39 +88,33 @@ class EnlyzeClient: """ def __init__(self, token: str, *, _base_url: str | None = None) -> None: - self._timeseries_api_client = TimeseriesApiClient( - token=token, - base_url=_base_url or ENLYZE_BASE_URL, - ) - self._production_runs_api_client = ProductionRunsApiClient( - token=token, - base_url=_base_url or ENLYZE_BASE_URL, + self._platform_api_client = PlatformApiClient( + token=token, base_url=_base_url or ENLYZE_BASE_URL ) - def _get_sites(self) -> Iterator[timeseries_api_models.Site]: + def _get_sites(self) -> Iterator[platform_api_models.Site]: """Get all sites from the API""" - return self._timeseries_api_client.get_paginated( - "sites", timeseries_api_models.Site + return self._platform_api_client.get_paginated( + "sites", platform_api_models.Site ) @cache def get_sites(self) -> list[user_models.Site]: """Retrieve all :ref:`sites ` of your organization. - :raises: |token-error| - - :raises: |generic-error| - :returns: Sites of your organization :rtype: list[:class:`~enlyze.models.Site`] + :raises: |token-error| + :raises: |generic-error| + """ return [site.to_user_model() for site in self._get_sites()] - def _get_machines(self) -> Iterator[timeseries_api_models.Machine]: + def _get_machines(self) -> Iterator[platform_api_models.Machine]: """Get all machines from the API""" - return self._timeseries_api_client.get_paginated( - "appliances", timeseries_api_models.Machine + return self._platform_api_client.get_paginated( + "machines", platform_api_models.Machine ) @cache @@ -134,23 +126,22 @@ def get_machines( :param site: Only get machines of this site. Gets all machines of the organization if None. - :raises: |token-error| - - :raises: |generic-error| - :returns: Machines :rtype: list[:class:`~enlyze.models.Machine`] + :raises: |token-error| + :raises: |generic-error| + """ if site: - sites_by_id = {site._id: site} + sites_by_uuid = {site.uuid: site} else: - sites_by_id = {site._id: site for site in self.get_sites()} + sites_by_uuid = {site.uuid: site for site in self.get_sites()} machines = [] for machine_api in self._get_machines(): - site_ = sites_by_id.get(machine_api.site) + site_ = sites_by_uuid.get(machine_api.site) if not site_: continue @@ -160,12 +151,12 @@ def get_machines( def _get_variables( self, machine_uuid: UUID - ) -> Iterator[timeseries_api_models.Variable]: + ) -> Iterator[platform_api_models.Variable]: """Get variables for a machine from the API.""" - return self._timeseries_api_client.get_paginated( + return self._platform_api_client.get_paginated( "variables", - timeseries_api_models.Variable, - params={"appliance": str(machine_uuid)}, + platform_api_models.Variable, + params={"machine": str(machine_uuid)}, ) def get_variables( @@ -175,12 +166,11 @@ def get_variables( :param machine: The machine for which to get all variables. - :raises: |token-error| + :returns: Variables of ``machine`` + :raises: |token-error| :raises: |generic-error| - :returns: Variables of ``machine`` - """ return [ variable.to_user_model(machine) @@ -195,9 +185,9 @@ def _get_paginated_timeseries( end: datetime, variables: Sequence[str], resampling_interval: Optional[int], - ) -> Iterator[timeseries_api_models.TimeseriesData]: + ) -> Iterator[platform_api_models.TimeseriesData]: params: dict[str, Any] = { - "appliance": machine_uuid, + "machine": machine_uuid, "start_datetime": start.isoformat(), "end_datetime": end.isoformat(), "variables": ",".join(variables), @@ -206,8 +196,8 @@ def _get_paginated_timeseries( if resampling_interval: params["resampling_interval"] = resampling_interval - return self._timeseries_api_client.get_paginated( - "timeseries", timeseries_api_models.TimeseriesData, params=params + return self._platform_api_client.get_paginated( + "timeseries", platform_api_models.TimeseriesData, params=params ) def _get_timeseries( @@ -262,7 +252,7 @@ def _get_timeseries( data is not None for data in timeseries_data_chunked ): raise EnlyzeError( - "The timeseries API didn't return data for some of the variables." + "The platform API didn't return data for some of the variables." ) try: @@ -297,13 +287,12 @@ def get_timeseries( :param end: End of the time frame for which to fetch timeseries data. :param variables: The variables for which to fetch timeseries data. - :raises: |token-error| - - :raises: |generic-error| - :returns: Timeseries data or ``None`` if the API returned no data for the request + :raises: |token-error| + :raises: |generic-error| + """ return self._get_timeseries(start, end, variables) @@ -336,15 +325,13 @@ def get_timeseries_with_resampling( with. Must be greater than or equal :const:`~enlyze.constants.MINIMUM_RESAMPLING_INTERVAL`. - :raises: |token-error| + :returns: Timeseries data or ``None`` if the API returned no data for the + request + :raises: |token-error| :raises: |resampling-error| - :raises: |generic-error| - :returns: Timeseries data or ``None`` if the API returned no data for the - request - """ # noqa: E501 return self._get_timeseries(start, end, variables, resampling_interval) @@ -356,19 +343,19 @@ def _get_production_runs( machine: Optional[UUID] = None, start: Optional[datetime] = None, end: Optional[datetime] = None, - ) -> Iterator[ProductionRun]: + ) -> Iterator[platform_api_models.ProductionRun]: """Get production runs from the API.""" filters = { "production_order": production_order, "product": product, - "appliance": machine, + "machine": machine, "start": start.isoformat() if start else None, "end": end.isoformat() if end else None, } params = {k: v for k, v in filters.items() if v is not None} - return self._production_runs_api_client.get_paginated( - "production-runs", ProductionRun, params=params + return self._platform_api_client.get_paginated( + "production-runs", platform_api_models.ProductionRun, params=params ) def get_production_runs( @@ -386,13 +373,12 @@ def get_production_runs( :param product: Filter production runs by product. :param production_order: Filter production runs by production order. - :raises: |token-error| - - :raises: |generic-error| - :returns: Production runs :rtype: :class:`~enlyze.models.ProductionRuns` + :raises: |token-error| + :raises: |generic-error| + """ if start and end: start, end = validate_start_and_end(start, end) diff --git a/src/enlyze/constants.py b/src/enlyze/constants.py index 1989dbe..dea9986 100644 --- a/src/enlyze/constants.py +++ b/src/enlyze/constants.py @@ -1,13 +1,10 @@ #: Base URL of the ENLYZE platform. ENLYZE_BASE_URL = "https://app.enlyze.com" -#: URL sub-path where the Timeseries API is deployed on the ENLYZE platform. -TIMESERIES_API_SUB_PATH = "api/timeseries/v1/" +#: URL sub-path of the ENLYZE platform API. +PLATFORM_API_SUB_PATH = "api/v2/" -#: URL sub-path where the Production Runs API is deployed on the ENLYZE platform. -PRODUCTION_RUNS_API_SUB_PATH = "api/production-runs/v1/" - -#: HTTP timeout for requests to the Timeseries API. +#: HTTP timeout for requests to the ENLYZE platform. #: #: Reference: https://www.python-httpx.org/advanced/timeouts/ HTTPX_TIMEOUT = 30.0 diff --git a/src/enlyze/errors.py b/src/enlyze/errors.py index 2cd3cc9..98ce146 100644 --- a/src/enlyze/errors.py +++ b/src/enlyze/errors.py @@ -33,8 +33,8 @@ class ResamplingValidationError(EnlyzeError): class DuplicateDisplayNameError(EnlyzeError): """Variables with duplicate display names - Resolving variable UUIDs to display names would result in ambiguity because - multiple variables have the same display name. You should either fix the - duplicate variable display names via the ENLYZE App or don't request them at - the same time. + Resolving variable UUIDs to display names would result in ambiguity because multiple + variables have the same display name. You should either fix the duplicate variable + display names via the ENLYZE App or don't request them at the same time. + """ diff --git a/src/enlyze/models.py b/src/enlyze/models.py index 62bc5fd..28b27ce 100644 --- a/src/enlyze/models.py +++ b/src/enlyze/models.py @@ -19,7 +19,8 @@ class Site: """ - _id: int + #: Stable identifier of the site. + uuid: UUID #: Display name of the site. display_name: str @@ -169,11 +170,11 @@ def to_dicts(self, use_display_names: bool = False) -> Iterator[dict[str, Any]]: :param use_display_names: Whether to return display names instead of variable UUIDs. If there is no display name, fall back to UUID. - :raises: :exc:`~enlyze.errors.DuplicateDisplayNameError` when duplicate - display names would be returned instead of UUIDs. - :returns: Iterator over rows + :raises: :exc:`~enlyze.errors.DuplicateDisplayNameError` when duplicate display + names would be returned instead of UUIDs. + """ time_column, *variable_columns = self._columns @@ -199,11 +200,11 @@ def to_dataframe(self, use_display_names: bool = False) -> pandas.DataFrame: :param use_display_names: Whether to return display names instead of variable UUIDs. If there is no display name, fall back to UUID. - :raises: :exc:`~enlyze.errors.DuplicateDisplayNameError` when duplicate - display names would be returned instead of UUIDs. - :returns: DataFrame with timeseries data indexed by time + :raises: :exc:`~enlyze.errors.DuplicateDisplayNameError` when duplicate display + names would be returned instead of UUIDs. + """ time_column, *variable_columns = self._columns @@ -224,7 +225,7 @@ def to_dataframe(self, use_display_names: bool = False) -> pandas.DataFrame: class OEEComponent: """Individual Overall Equipment Effectiveness (OEE) score - This is calculated by the ENLYZE Platform based on a combination of real machine + This is calculated by the ENLYZE platform based on a combination of real machine data and production order booking information provided by the customer. For more information, please check out https://www.oee.com @@ -322,6 +323,7 @@ def to_dataframe(self) -> pandas.DataFrame: ` :py:class:`datetime.datetime` localized in UTC. :returns: DataFrame with production runs. + """ if not self: return pandas.DataFrame() diff --git a/src/enlyze/api_clients/production_runs/__init__.py b/tests/enlyze/api_client/__init__.py similarity index 100% rename from src/enlyze/api_clients/production_runs/__init__.py rename to tests/enlyze/api_client/__init__.py diff --git a/tests/enlyze/api_client/test_client.py b/tests/enlyze/api_client/test_client.py new file mode 100644 index 0000000..0b44e23 --- /dev/null +++ b/tests/enlyze/api_client/test_client.py @@ -0,0 +1,298 @@ +import string +from unittest.mock import patch + +import httpx +import pytest +import respx +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from enlyze._version import VERSION +from enlyze.api_client.client import ( + USER_AGENT_NAME_VERSION_SEPARATOR, + PlatformApiClient, + PlatformApiModel, + _construct_user_agent, + _Metadata, + _PaginatedResponse, +) +from enlyze.constants import USER_AGENT +from enlyze.errors import EnlyzeError, InvalidTokenError + + +def _paginated_responses_to_expected_data( + model: PlatformApiModel, paginated_responses: list[_PaginatedResponse] +) -> list: + expected = [] + for r in paginated_responses: + data = r.data if isinstance(r.data, list) else [r.data] + validated = [model.model_validate(e) for e in data] + expected.extend(validated) + return expected + + +@pytest.fixture +def string_model(): + with patch( + "enlyze.api_client.models.PlatformApiModel.model_validate", + side_effect=lambda o: str(o), + ): + yield PlatformApiModel + + +@pytest.fixture +def base_url(): + return "http://api-client-base" + + +@pytest.fixture +def api_client(auth_token, base_url): + return PlatformApiClient(token=auth_token, base_url=base_url) + + +@pytest.fixture +def api_client_base_url(api_client): + return api_client._client.base_url + + +@pytest.fixture +def last_page_metadata(): + return _Metadata(next_cursor=None) + + +@pytest.fixture +def next_page_metadata(): + return _Metadata(next_cursor="100") + + +@pytest.fixture +def response_data_dict() -> dict: + return {"some": "dictionary"} + + +@pytest.fixture +def response_data_list(response_data_dict) -> list: + return [response_data_dict] + + +@pytest.fixture +def empty_paginated_response(last_page_metadata): + return _PaginatedResponse(data=[], metadata=last_page_metadata) + + +@pytest.fixture +def paginated_response_list_no_next_page(response_data_list, last_page_metadata): + return _PaginatedResponse(data=response_data_list, metadata=last_page_metadata) + + +@pytest.fixture +def paginated_response_dict_no_next_page(response_data_dict, last_page_metadata): + return _PaginatedResponse(data=response_data_dict, metadata=last_page_metadata) + + +@pytest.fixture +def paginated_response_list_with_next_page(response_data_list, next_page_metadata): + return _PaginatedResponse(data=response_data_list, metadata=next_page_metadata) + + +@pytest.fixture +def paginated_response_dict_with_next_page(response_data_dict, next_page_metadata): + return _PaginatedResponse(data=response_data_dict, metadata=next_page_metadata) + + +@pytest.fixture +def custom_user_agent(): + return "custom-user-agent" + + +@pytest.fixture +def custom_user_agent_version(): + return "3.4.5" + + +class TestConstructUserAgent: + def test__construct_user_agent_with_defaults(self): + ua, version = _construct_user_agent().split(USER_AGENT_NAME_VERSION_SEPARATOR) + assert ua == USER_AGENT + assert version == VERSION + + def test__construct_user_agent_custom_agent(self, custom_user_agent): + ua, version = _construct_user_agent(user_agent=custom_user_agent).split( + USER_AGENT_NAME_VERSION_SEPARATOR + ) + assert ua == custom_user_agent + assert version == VERSION + + def test__construct_user_agent_custom_version(self, custom_user_agent_version): + ua, version = _construct_user_agent(version=custom_user_agent_version).split( + USER_AGENT_NAME_VERSION_SEPARATOR + ) + assert ua == USER_AGENT + assert version == custom_user_agent_version + + def test__construct_user_agent_custom_agent_and_version( + self, custom_user_agent, custom_user_agent_version + ): + ua, version = _construct_user_agent( + user_agent=custom_user_agent, version=custom_user_agent_version + ).split(USER_AGENT_NAME_VERSION_SEPARATOR) + assert ua == custom_user_agent + assert version == custom_user_agent_version + + +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) +@given( + token=st.text(string.printable, min_size=1), +) +@respx.mock +def test_token_auth(token, base_url): + route_is_authenticated = respx.get( + "", + headers__contains={"Authorization": f"Bearer {token}"}, + ).respond(json={}) + + api_client = PlatformApiClient(base_url=base_url, token=token) + api_client.get("") + assert route_is_authenticated.called + + +@respx.mock +def test_base_url(api_client, api_client_base_url): + endpoint = "some-endpoint" + + route = respx.get( + httpx.URL(api_client_base_url).join(endpoint), + ).respond(json={}) + + api_client.get(endpoint) + assert route.called + + +@respx.mock +def test_get_raises_cannot_read(api_client): + with pytest.raises(EnlyzeError, match="Couldn't read"): + respx.get("").mock(side_effect=Exception("oops")) + api_client.get("") + + +@respx.mock +def test_get_raises_on_error(api_client): + with pytest.raises(EnlyzeError, match="returned error 404"): + respx.get("").respond(404) + api_client.get("") + + +@respx.mock +def test_get_raises_invalid_token_error_not_authenticated(api_client): + with pytest.raises(InvalidTokenError): + respx.get("").respond(403) + api_client.get("") + + +@respx.mock +def test_get_raises_non_json(api_client): + with pytest.raises(EnlyzeError, match="didn't return a valid JSON object"): + respx.get("").respond(200, json=None) + api_client.get("") + + +@pytest.mark.parametrize( + "invalid_payload", + [ + "not a paginated response", + {"data": "something but not a list"}, + ], +) +@respx.mock +def test_get_paginated_raises_invalid_pagination_schema( + api_client, string_model, invalid_payload +): + with pytest.raises(EnlyzeError, match="Paginated response expected"): + respx.get("").respond(json=invalid_payload) + next(api_client.get_paginated("", string_model)) + + +@pytest.mark.parametrize( + "paginated_response_no_next_page_fixture", + ["paginated_response_list_no_next_page", "paginated_response_dict_no_next_page"], +) +@respx.mock +def test_get_paginated_single_page( + api_client, + string_model, + paginated_response_no_next_page_fixture, + request, +): + paginated_response_no_next_page = request.getfixturevalue( + paginated_response_no_next_page_fixture + ) + params = {"params": {"param1": "value1"}} + expected_data = _paginated_responses_to_expected_data( + string_model, [paginated_response_no_next_page] + ) + + route = respx.get("", params=params).respond( + 200, json=paginated_response_no_next_page.model_dump() + ) + + data = list(api_client.get_paginated("", string_model, params=params)) + + assert route.called + assert route.call_count == 1 + assert expected_data == data + + +@pytest.mark.parametrize( + "paginated_response_with_next_page_fixture,paginated_response_no_next_page_fixture", + [ + [ + "paginated_response_dict_with_next_page", + "paginated_response_dict_no_next_page", + ], + [ + "paginated_response_list_with_next_page", + "paginated_response_list_no_next_page", + ], + ], +) +@respx.mock +def test_get_paginated_multi_page( + api_client, + paginated_response_with_next_page_fixture, + paginated_response_no_next_page_fixture, + string_model, + request, +): + initial_params = {"irrelevant": "values"} + responses = [ + request.getfixturevalue(paginated_response_with_next_page_fixture), + request.getfixturevalue(paginated_response_no_next_page_fixture), + ] + + expected_data = _paginated_responses_to_expected_data(string_model, responses) + + route = respx.get("", params=initial_params) + route.side_effect = [httpx.Response(200, json=r.model_dump()) for r in responses] + + data = list(api_client.get_paginated("", PlatformApiModel, params=initial_params)) + + assert route.called + assert route.call_count == 2 + assert data == expected_data + + +@respx.mock +def test_get_paginated_raises_on_invalid_data(api_client): + class TestModel(PlatformApiModel): + my_field: int # type:ignore + + invalid_data = [{"invalid": "data"}] + paginated_response = _PaginatedResponse( + data=invalid_data, metadata=_Metadata(next_cursor=None) + ) + route = respx.get("").respond(json=paginated_response.model_dump()) + + with pytest.raises(EnlyzeError, match="ENLYZE platform API returned an unparsable"): + list(api_client.get_paginated("", TestModel)) + + assert route.called diff --git a/tests/enlyze/api_clients/timeseries/test_models.py b/tests/enlyze/api_client/test_models.py similarity index 98% rename from tests/enlyze/api_clients/timeseries/test_models.py rename to tests/enlyze/api_client/test_models.py index 7e6683c..68470bc 100644 --- a/tests/enlyze/api_clients/timeseries/test_models.py +++ b/tests/enlyze/api_client/test_models.py @@ -4,7 +4,7 @@ import pytest -from enlyze.api_clients.timeseries.models import TimeseriesData +from enlyze.api_client.models import TimeseriesData # We use this to skip columns that contain the timestamp assuming # it starts at the beginning of the sequence. We also use it diff --git a/tests/enlyze/api_clients/__init__.py b/tests/enlyze/api_clients/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/enlyze/api_clients/conftest.py b/tests/enlyze/api_clients/conftest.py deleted file mode 100644 index 1773257..0000000 --- a/tests/enlyze/api_clients/conftest.py +++ /dev/null @@ -1,24 +0,0 @@ -from unittest.mock import patch - -import pytest - -from enlyze.api_clients.base import ApiBaseModel - - -@pytest.fixture -def string_model(): - with patch( - "enlyze.api_clients.base.ApiBaseModel.model_validate", - side_effect=lambda o: str(o), - ): - yield ApiBaseModel - - -@pytest.fixture -def endpoint(): - return "https://my-endpoint.com" - - -@pytest.fixture -def base_url(): - return "http://api-client-base" diff --git a/tests/enlyze/api_clients/production_runs/__init__.py b/tests/enlyze/api_clients/production_runs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/enlyze/api_clients/production_runs/test_client.py b/tests/enlyze/api_clients/production_runs/test_client.py deleted file mode 100644 index f64167d..0000000 --- a/tests/enlyze/api_clients/production_runs/test_client.py +++ /dev/null @@ -1,117 +0,0 @@ -import httpx -import pytest -import respx - -from enlyze.api_clients.production_runs.client import ( - ProductionRunsApiClient, - _Metadata, - _PaginatedResponse, -) -from enlyze.constants import PRODUCTION_RUNS_API_SUB_PATH - - -@pytest.fixture -def metadata_last_page(): - return _Metadata(has_more=False, next_cursor=None) - - -@pytest.fixture -def metadata_next_page(): - return _Metadata(has_more=True, next_cursor=1337) - - -@pytest.fixture -def response_data(): - return [{"id": i, "name": f"row-{i}"} for i in range(10)] - - -@pytest.fixture -def paginated_response_no_next_page(response_data, metadata_last_page): - return _PaginatedResponse(data=response_data, metadata=metadata_last_page) - - -@pytest.fixture -def paginated_response_with_next_page(response_data, metadata_next_page): - return _PaginatedResponse(data=response_data, metadata=metadata_next_page) - - -@pytest.fixture -def production_runs_client(auth_token, base_url): - return ProductionRunsApiClient(token=auth_token, base_url=base_url) - - -def test_timeseries_api_appends_sub_path(auth_token, base_url): - expected = str(httpx.URL(base_url).join(PRODUCTION_RUNS_API_SUB_PATH)) - client = ProductionRunsApiClient(token=auth_token, base_url=base_url) - assert client._full_url("") == expected - - -@pytest.mark.parametrize( - ("response_fixture", "expected_has_more"), - ( - ("paginated_response_no_next_page", False), - ("paginated_response_with_next_page", True), - ), -) -def test_has_more(request, response_fixture, expected_has_more, production_runs_client): - response = request.getfixturevalue(response_fixture) - assert production_runs_client._has_more(response) == expected_has_more - - -def test_next_page_call_args( - production_runs_client, endpoint, paginated_response_with_next_page -): - params = {"some": "param"} - kwargs = {"some": "kwarg"} - url = endpoint - next_url, next_params, next_kwargs = production_runs_client._next_page_call_args( - url=url, - params=params, - paginated_response=paginated_response_with_next_page, - **kwargs, - ) - assert next_url == url - assert next_params == { - **params, - "cursor": paginated_response_with_next_page.metadata.next_cursor, - } - assert next_kwargs == kwargs - - -@respx.mock -def test_timeseries_api_get_paginated_single_page( - production_runs_client, string_model, paginated_response_no_next_page -): - expected_data = [ - string_model.model_validate(e) for e in paginated_response_no_next_page.data - ] - respx.get("").respond(json=paginated_response_no_next_page.model_dump()) - assert list(production_runs_client.get_paginated("", string_model)) == expected_data - - -@respx.mock -def test_timeseries_api_get_paginated_multi_page( - production_runs_client, - string_model, - paginated_response_with_next_page, - paginated_response_no_next_page, -): - expected_data = [ - string_model.model_validate(e) - for e in [ - *paginated_response_no_next_page.data, - *paginated_response_with_next_page.data, - ] - ] - next_cursor = paginated_response_with_next_page.metadata.next_cursor - respx.get("", params=f"cursor={next_cursor}").respond( - 200, json=paginated_response_no_next_page.model_dump() - ) - respx.get("").mock( - side_effect=lambda request: httpx.Response( - 200, - json=paginated_response_with_next_page.model_dump(), - ) - ) - - assert list(production_runs_client.get_paginated("", string_model)) == expected_data diff --git a/tests/enlyze/api_clients/test_base.py b/tests/enlyze/api_clients/test_base.py deleted file mode 100644 index 897cd81..0000000 --- a/tests/enlyze/api_clients/test_base.py +++ /dev/null @@ -1,333 +0,0 @@ -import string -from unittest.mock import MagicMock, call, patch - -import httpx -import pytest -import respx -from hypothesis import HealthCheck, given, settings -from hypothesis import strategies as st - -from enlyze._version import VERSION -from enlyze.api_clients.base import ( - USER_AGENT_NAME_VERSION_SEPARATOR, - ApiBaseClient, - ApiBaseModel, - PaginatedResponseBaseModel, - _construct_user_agent, -) -from enlyze.constants import USER_AGENT -from enlyze.errors import EnlyzeError, InvalidTokenError - - -class Metadata(ApiBaseModel): - has_more: bool - next_cursor: int | None = None - - -class PaginatedResponseModel(PaginatedResponseBaseModel): - metadata: Metadata - data: list - - -def _transform_paginated_data_integers(data: list) -> list: - return [n * n for n in data] - - -@pytest.fixture -def last_page_metadata(): - return Metadata(has_more=False, next_cursor=None) - - -@pytest.fixture -def next_page_metadata(): - return Metadata(has_more=True, next_cursor=100) - - -@pytest.fixture -def empty_paginated_response(last_page_metadata): - return PaginatedResponseModel(data=[], metadata=last_page_metadata) - - -@pytest.fixture -def response_data_integers(): - return list(range(20)) - - -@pytest.fixture -def paginated_response_with_next_page(response_data_integers, next_page_metadata): - return PaginatedResponseModel( - data=response_data_integers, metadata=next_page_metadata - ) - - -@pytest.fixture -def paginated_response_no_next_page(response_data_integers, last_page_metadata): - return PaginatedResponseModel( - data=response_data_integers, metadata=last_page_metadata - ) - - -@pytest.fixture -def base_client(auth_token, string_model, base_url): - mock_has_more = MagicMock() - mock_transform_paginated_response_data = MagicMock(side_effect=lambda e: e) - mock_next_page_call_args = MagicMock() - with patch.multiple( - ApiBaseClient, - __abstractmethods__=set(), - _has_more=mock_has_more, - _next_page_call_args=mock_next_page_call_args, - _transform_paginated_response_data=mock_transform_paginated_response_data, - ): - client = ApiBaseClient[PaginatedResponseModel]( - token=auth_token, - base_url=base_url, - ) - client.PaginatedResponseModel = PaginatedResponseModel - yield client - - -@pytest.fixture -def custom_user_agent(): - return "custom-user-agent" - - -@pytest.fixture -def custom_user_agent_version(): - return "3.4.5" - - -class TestConstructUserAgent: - def test__construct_user_agent_with_defaults(self): - ua, version = _construct_user_agent().split(USER_AGENT_NAME_VERSION_SEPARATOR) - assert ua == USER_AGENT - assert version == VERSION - - def test__construct_user_agent_custom_agent(self, custom_user_agent): - ua, version = _construct_user_agent(user_agent=custom_user_agent).split( - USER_AGENT_NAME_VERSION_SEPARATOR - ) - assert ua == custom_user_agent - assert version == VERSION - - def test__construct_user_agent_custom_version(self, custom_user_agent_version): - ua, version = _construct_user_agent(version=custom_user_agent_version).split( - USER_AGENT_NAME_VERSION_SEPARATOR - ) - assert ua == USER_AGENT - assert version == custom_user_agent_version - - def test__construct_user_agent_custom_agent_and_version( - self, custom_user_agent, custom_user_agent_version - ): - ua, version = _construct_user_agent( - user_agent=custom_user_agent, version=custom_user_agent_version - ).split(USER_AGENT_NAME_VERSION_SEPARATOR) - assert ua == custom_user_agent - assert version == custom_user_agent_version - - -@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) -@given( - token=st.text(string.printable, min_size=1), -) -@respx.mock -def test_token_auth(token, base_url): - with patch.multiple(ApiBaseClient, __abstractmethods__=set()): - client = ApiBaseClient(token=token, base_url=base_url) - - route_is_authenticated = respx.get( - "", - headers__contains={"Authorization": f"Token {token}"}, - ).respond(json={}) - - client.get("") - assert route_is_authenticated.called - - -@respx.mock -def test_base_url(base_client, base_url): - endpoint = "some-endpoint" - - route = respx.get( - httpx.URL(base_url).join(endpoint), - ).respond(json={}) - - base_client.get(endpoint) - assert route.called - - -@respx.mock -def test_get_raises_cannot_read(base_client): - with pytest.raises(EnlyzeError, match="Couldn't read"): - respx.get("").mock(side_effect=Exception("oops")) - base_client.get("") - - -@respx.mock -def test_get_raises_on_error(base_client): - with pytest.raises(EnlyzeError, match="returned error 404"): - respx.get("").respond(404) - base_client.get("") - - -@respx.mock -def test_get_raises_invalid_token_error_not_authenticated(base_client): - with pytest.raises(InvalidTokenError): - respx.get("").respond(403) - base_client.get("") - - -@respx.mock -def test_get_raises_non_json(base_client): - with pytest.raises(EnlyzeError, match="didn't return a valid JSON object"): - respx.get("").respond(200, json=None) - base_client.get("") - - -@respx.mock -def test_get_paginated_single_page( - base_client, string_model, paginated_response_no_next_page -): - endpoint = "https://irrelevant-url.com" - params = {"params": {"param1": "value1"}} - expected_data = [ - string_model.model_validate(e) for e in paginated_response_no_next_page.data - ] - - mock_has_more = base_client._has_more - mock_has_more.return_value = False - route = respx.get(endpoint, params=params).respond( - 200, json=paginated_response_no_next_page.model_dump() - ) - - data = list(base_client.get_paginated(endpoint, ApiBaseModel, params=params)) - - assert route.called - assert route.call_count == 1 - assert expected_data == data - mock_has_more.assert_called_once_with(paginated_response_no_next_page) - - -@respx.mock -def test_get_paginated_multi_page( - base_client, - paginated_response_with_next_page, - paginated_response_no_next_page, - string_model, -): - endpoint = "https://irrelevant-url.com" - initial_params = {"irrelevant": "values"} - expected_data = [ - string_model.model_validate(e) - for e in [ - *paginated_response_with_next_page.data, - *paginated_response_no_next_page.data, - ] - ] - - mock_has_more = base_client._has_more - mock_has_more.side_effect = [True, False] - - mock_next_page_call_args = base_client._next_page_call_args - mock_next_page_call_args.return_value = (endpoint, {}, {}) - - route = respx.get(endpoint) - route.side_effect = [ - httpx.Response(200, json=paginated_response_with_next_page.model_dump()), - httpx.Response(200, json=paginated_response_no_next_page.model_dump()), - ] - - data = list( - base_client.get_paginated(endpoint, ApiBaseModel, params=initial_params) - ) - - assert route.called - assert route.call_count == 2 - assert data == expected_data - mock_has_more.assert_has_calls( - [ - call(paginated_response_with_next_page), - call(paginated_response_no_next_page), - ] - ) - mock_next_page_call_args.assert_called_once_with( - url=endpoint, - params=initial_params, - paginated_response=paginated_response_with_next_page, - ) - - -@pytest.mark.parametrize( - "invalid_payload", - [ - "not a paginated response", - {"data": "something but not a list"}, - ], -) -@respx.mock -def test_get_paginated_raises_invalid_pagination_schema( - base_client, - invalid_payload, -): - with pytest.raises(EnlyzeError, match="Paginated response expected"): - respx.get("").respond(json=invalid_payload) - next( - base_client.get_paginated( - "", - ApiBaseModel, - ) - ) - - -@respx.mock -def test_get_paginated_raises_enlyze_error( - base_client, string_model, paginated_response_no_next_page -): - # most straightforward way to raise a pydantic.ValidationError - # https://github.com/pydantic/pydantic/discussions/6459 - string_model.model_validate.side_effect = lambda _: Metadata() - respx.get("").respond(200, json=paginated_response_no_next_page.model_dump()) - - with pytest.raises(EnlyzeError, match="ENLYZE platform API returned an unparsable"): - next(base_client.get_paginated("", string_model)) - - -@respx.mock -def test_get_paginated_transform_paginated_data( - base_client, paginated_response_no_next_page, string_model -): - base_client._has_more.return_value = False - base_client._transform_paginated_response_data.side_effect = ( - _transform_paginated_data_integers - ) - expected_data = [ - string_model.model_validate(e) - for e in _transform_paginated_data_integers( - paginated_response_no_next_page.data - ) - ] - - route = respx.get("").respond( - 200, json=paginated_response_no_next_page.model_dump() - ) - - data = list(base_client.get_paginated("", ApiBaseModel)) - - base_client._transform_paginated_response_data.assert_called_once_with( - paginated_response_no_next_page.data - ) - - assert route.called - assert route.call_count == 1 - assert data == expected_data - - -def test_transform_paginated_data_returns_unmutated_element_by_default( - auth_token, base_url -): - with patch.multiple(ApiBaseClient, __abstractmethods__=set()): - client = ApiBaseClient(token=auth_token, base_url=base_url) - data = [1, 2, 3] - value = client._transform_paginated_response_data(data) - assert data == value diff --git a/tests/enlyze/api_clients/timeseries/__init__.py b/tests/enlyze/api_clients/timeseries/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/enlyze/api_clients/timeseries/test_client.py b/tests/enlyze/api_clients/timeseries/test_client.py deleted file mode 100644 index 035b5dc..0000000 --- a/tests/enlyze/api_clients/timeseries/test_client.py +++ /dev/null @@ -1,108 +0,0 @@ -import httpx -import pytest -import respx - -from enlyze.api_clients.timeseries.client import TimeseriesApiClient, _PaginatedResponse -from enlyze.constants import TIMESERIES_API_SUB_PATH - - -@pytest.fixture -def response_data_list() -> list: - return [1, 2, 3] - - -@pytest.fixture -def response_data_dict() -> dict: - return {"some": "dictionary"} - - -@pytest.fixture -def transformed_data_dict(response_data_dict) -> list[dict]: - return [response_data_dict] - - -@pytest.fixture -def paginated_response_no_next_page(): - return _PaginatedResponse(data=[], next=None) - - -@pytest.fixture -def paginated_response_with_next_page(endpoint): - return _PaginatedResponse( - data=[], - next=f"{endpoint}?offset=1337", - ) - - -@pytest.fixture -def timeseries_client(auth_token, base_url): - return TimeseriesApiClient(token=auth_token, base_url=base_url) - - -def test_timeseries_api_appends_sub_path(auth_token, base_url): - expected = str(httpx.URL(base_url).join(TIMESERIES_API_SUB_PATH)) - client = TimeseriesApiClient(token=auth_token, base_url=base_url) - assert client._full_url("") == expected - - -@pytest.mark.parametrize( - ("response_fixture", "expected_has_more"), - ( - ("paginated_response_no_next_page", False), - ("paginated_response_with_next_page", True), - ), -) -def test_has_more(request, response_fixture, expected_has_more, timeseries_client): - response = request.getfixturevalue(response_fixture) - assert timeseries_client._has_more(response) == expected_has_more - - -@pytest.mark.parametrize( - ("data_fixture", "expected_fixture"), - ( - ("response_data_list", "response_data_list"), - ("response_data_dict", "transformed_data_dict"), - ), -) -def test_get_paginated_transform_paginated_data( - request, timeseries_client, data_fixture, expected_fixture -): - data = request.getfixturevalue(data_fixture) - expected = request.getfixturevalue(expected_fixture) - assert timeseries_client._transform_paginated_response_data(data) == expected - - -def test_next_page_call_args( - timeseries_client, endpoint, paginated_response_with_next_page -): - params = {"some": "param"} - kwargs = {"some": "kwarg"} - url = endpoint - next_url, next_params, next_kwargs = timeseries_client._next_page_call_args( - url=url, - params=params, - paginated_response=paginated_response_with_next_page, - **kwargs, - ) - assert next_url == str(paginated_response_with_next_page.next) - assert next_params == params - assert next_kwargs == kwargs - - -@respx.mock -def test_timeseries_api_get_paginated_single_page(timeseries_client, string_model): - respx.get("").respond(json={"data": ["a", "b"], "next": None}) - assert list(timeseries_client.get_paginated("", string_model)) == ["a", "b"] - - -@respx.mock -def test_timeseries_api_get_paginated_multi_page(timeseries_client, string_model): - respx.get("", params="offset=1").respond(json={"data": ["z"], "next": None}) - respx.get("").mock( - side_effect=lambda request: httpx.Response( - 200, - json={"data": ["x", "y"], "next": str(request.url.join("?offset=1"))}, - ) - ) - - assert list(timeseries_client.get_paginated("", string_model)) == ["x", "y", "z"] diff --git a/tests/enlyze/test_auth.py b/tests/enlyze/test_auth.py index a16965e..c493415 100644 --- a/tests/enlyze/test_auth.py +++ b/tests/enlyze/test_auth.py @@ -17,7 +17,7 @@ def test_token_auth(token): response = httpx.get("https://foo.bar/", auth=auth) assert my_route.called - assert response.request.headers["Authorization"] == f"Token {token}" + assert response.request.headers["Authorization"] == f"Bearer {token}" @pytest.mark.parametrize("invalid_token", {"", None, 0}) diff --git a/tests/enlyze/test_client.py b/tests/enlyze/test_client.py index d6e8f5b..62266ac 100644 --- a/tests/enlyze/test_client.py +++ b/tests/enlyze/test_client.py @@ -8,23 +8,13 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -import enlyze.api_clients.production_runs.models as production_runs_api_models -import enlyze.api_clients.timeseries.models as timeseries_api_models +import enlyze.api_client.models as platform_api_models import enlyze.models as user_models -from enlyze.api_clients.production_runs.client import ( - _Metadata as _ProductionRunsApiResponseMetadata, -) -from enlyze.api_clients.production_runs.client import ( - _PaginatedResponse as _PaginatedProductionRunsResponse, -) -from enlyze.api_clients.timeseries.client import ( - _PaginatedResponse as _PaginatedTimeseriesResponse, -) +from enlyze.api_client.client import _Metadata, _PaginatedResponse from enlyze.client import EnlyzeClient from enlyze.constants import ( ENLYZE_BASE_URL, - PRODUCTION_RUNS_API_SUB_PATH, - TIMESERIES_API_SUB_PATH, + PLATFORM_API_SUB_PATH, ) from enlyze.errors import EnlyzeError, ResamplingValidationError from tests.conftest import ( @@ -37,35 +27,34 @@ MACHINE_UUID = "ebef7e5a-5921-4cf3-9a52-7ff0e98e8306" PRODUCT_CODE = "product-code" PRODUCTION_ORDER = "production-order" -SITE_ID = 1 +SITE_UUID_ONE = "4e655719-03e8-465e-9e24-db42c2d6735a" +SITE_UUID_TWO = "088da69d-356a-41f8-819e-04c38592f0ac" create_float_strategy = partial( st.floats, allow_nan=False, allow_infinity=False, allow_subnormal=False ) oee_score_strategy = st.builds( - production_runs_api_models.OEEComponent, + platform_api_models.OEEComponent, score=create_float_strategy(min_value=0, max_value=1.0), time_loss=st.just(10), ) quantity_strategy = st.builds( - production_runs_api_models.Quantity, + platform_api_models.Quantity, value=create_float_strategy(min_value=0, max_value=1.0), ) production_runs_strategy = st.lists( st.builds( - production_runs_api_models.ProductionRun, + platform_api_models.ProductionRun, uuid=st.uuids(), start=datetime_before_today_strategy, end=datetime_today_until_now_strategy, - appliance=st.builds( - production_runs_api_models.Machine, uuid=st.just(MACHINE_UUID) - ), + machine=st.just(MACHINE_UUID), product=st.builds( - production_runs_api_models.Product, + platform_api_models.Product, code=st.just(PRODUCT_CODE), ), production_order=st.just(PRODUCTION_ORDER), @@ -92,23 +81,13 @@ def end_datetime(): return datetime.now() -class PaginatedTimeseriesApiResponse(httpx.Response): - def __init__(self, data, next=None) -> None: - super().__init__( - status_code=HTTPStatus.OK, - text=_PaginatedTimeseriesResponse(data=data, next=next).model_dump_json(), - headers=MOCK_RESPONSE_HEADERS, - ) - - -class PaginatedProductionRunsApiResponse(httpx.Response): - def __init__(self, data, has_more=False, next_cursor=None) -> None: +class PaginatedPlatformApiResponse(httpx.Response): + def __init__(self, data: list | dict, next_cursor=None) -> None: super().__init__( status_code=HTTPStatus.OK, - text=_PaginatedProductionRunsResponse( + text=_PaginatedResponse( data=data, - metadata=_ProductionRunsApiResponseMetadata( - has_more=has_more, + metadata=_Metadata( next_cursor=next_cursor, ), ).model_dump_json(), @@ -126,33 +105,39 @@ def make_client(): @given( - site1=st.builds(timeseries_api_models.Site), - site2=st.builds(timeseries_api_models.Site), + site1=st.builds(platform_api_models.Site), + site2=st.builds(platform_api_models.Site), ) def test_get_sites(site1, site2): client = make_client() - with respx_mock_with_base_url(TIMESERIES_API_SUB_PATH) as mock: - mock.get("sites").mock(PaginatedTimeseriesApiResponse(data=[site1, site2])) + with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: + mock.get("sites").mock( + PaginatedPlatformApiResponse(data=[s.model_dump() for s in [site1, site2]]) + ) sites = client.get_sites() assert sites == [site1.to_user_model(), site2.to_user_model()] @given( - site1=st.builds(timeseries_api_models.Site, id=st.just(1)), - site2=st.builds(timeseries_api_models.Site, id=st.just(2)), - machine1=st.builds(timeseries_api_models.Machine, site=st.just(1)), - machine2=st.builds(timeseries_api_models.Machine, site=st.just(2)), + site1=st.builds(platform_api_models.Site, uuid=st.just(SITE_UUID_ONE)), + site2=st.builds(platform_api_models.Site, uuid=st.just(SITE_UUID_TWO)), + machine1=st.builds(platform_api_models.Machine, site=st.just(SITE_UUID_ONE)), + machine2=st.builds(platform_api_models.Machine, site=st.just(SITE_UUID_TWO)), ) def test_get_machines(site1, site2, machine1, machine2): client = make_client() - with respx_mock_with_base_url(TIMESERIES_API_SUB_PATH) as mock: - mock.get("appliances").mock( - PaginatedTimeseriesApiResponse(data=[machine1, machine2]) + with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: + mock.get("machines").mock( + PaginatedPlatformApiResponse( + data=[m.model_dump() for m in [machine1, machine2]] + ) + ) + mock.get("sites").mock( + PaginatedPlatformApiResponse(data=[s.model_dump() for s in [site1, site2]]) ) - mock.get("sites").mock(PaginatedTimeseriesApiResponse(data=[site1, site2])) all_machines = client.get_machines() assert all_machines == [ @@ -167,28 +152,32 @@ def test_get_machines(site1, site2, machine1, machine2): @given( - machine=st.builds(timeseries_api_models.Machine), + machine=st.builds(platform_api_models.Machine), ) def test_get_machines_site_not_found(machine): client = make_client() - with respx_mock_with_base_url(TIMESERIES_API_SUB_PATH) as mock: - mock.get("sites").mock(PaginatedTimeseriesApiResponse(data=[])) - mock.get("appliances").mock(PaginatedTimeseriesApiResponse(data=[machine])) + with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: + mock.get("sites").mock(PaginatedPlatformApiResponse(data=[])) + mock.get("machines").mock( + PaginatedPlatformApiResponse(data=[machine.model_dump()]) + ) assert client.get_machines() == [] @given( machine=st.builds(user_models.Machine), - var1=st.builds(timeseries_api_models.Variable), - var2=st.builds(timeseries_api_models.Variable), + var1=st.builds(platform_api_models.Variable), + var2=st.builds(platform_api_models.Variable), ) def test_get_variables(machine, var1, var2): client = make_client() - with respx_mock_with_base_url(TIMESERIES_API_SUB_PATH) as mock: - mock.get("variables").mock(PaginatedTimeseriesApiResponse(data=[var1, var2])) + with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: + mock.get("variables").mock( + PaginatedPlatformApiResponse(data=[v.model_dump() for v in [var1, var2]]) + ) variables = client.get_variables(machine) assert variables == [ @@ -236,10 +225,11 @@ def test_get_timeseries( client = make_client() variable = data.draw(variable_strategy) - with respx_mock_with_base_url(TIMESERIES_API_SUB_PATH) as mock: - mock.get("timeseries", params="offset=1").mock( - PaginatedTimeseriesApiResponse( - data=timeseries_api_models.TimeseriesData( + with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: + cursor = "next-1" + mock.get("timeseries", params=f"cursor={cursor}").mock( + PaginatedPlatformApiResponse( + data=platform_api_models.TimeseriesData( columns=["time", str(variable.uuid)], records=records[1:], ).model_dump() @@ -247,12 +237,12 @@ def test_get_timeseries( ) mock.get("timeseries").mock( - side_effect=lambda request: PaginatedTimeseriesApiResponse( - data=timeseries_api_models.TimeseriesData( + side_effect=lambda request: PaginatedPlatformApiResponse( + data=platform_api_models.TimeseriesData( columns=["time", str(variable.uuid)], records=records[:1], ).model_dump(), - next=str(request.url.join("?offset=1")), + next_cursor=cursor, ) ) if timeseries_call == "without_resampling": @@ -285,7 +275,7 @@ def test_get_timeseries( "data", [ {}, - timeseries_api_models.TimeseriesData(columns=[], records=[]).model_dump(), + platform_api_models.TimeseriesData(columns=[], records=[]).model_dump(), ], ) @pytest.mark.parametrize( @@ -319,8 +309,8 @@ def test_get_timeseries_returns_none_on_empty_response( variable = data_strategy.draw(variable_strategy) client = make_client() - with respx_mock_with_base_url(TIMESERIES_API_SUB_PATH) as mock: - mock.get("timeseries").mock(PaginatedTimeseriesApiResponse(data=data)) + with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: + mock.get("timeseries").mock(PaginatedPlatformApiResponse(data=data)) if timeseries_call == "without_resampling": assert ( client.get_timeseries(start_datetime, end_datetime, [variable]) is None @@ -345,7 +335,7 @@ def test_get_timeseries_returns_none_on_empty_response( min_size=2, max_size=5, ), - machine=st.builds(timeseries_api_models.Machine, uuid=st.just(MACHINE_UUID)), + machine=st.builds(platform_api_models.Machine, uuid=st.just(MACHINE_UUID)), ) @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) def test__get_timeseries_raises_on_mixed_response( @@ -357,7 +347,7 @@ def test__get_timeseries_raises_on_mixed_response( machine, ): """ - Tests that an `EnlyzeError` is raised if the timeseries API returns + Tests that an `EnlyzeError` is raised if the platform API returns data for some of the variables but not all of them. """ @@ -381,11 +371,11 @@ def test__get_timeseries_raises_on_mixed_response( ) ) - with respx_mock_with_base_url(TIMESERIES_API_SUB_PATH) as mock: + with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: mock.get("timeseries").mock( side_effect=[ - PaginatedTimeseriesApiResponse( - data=timeseries_api_models.TimeseriesData( + PaginatedPlatformApiResponse( + data=platform_api_models.TimeseriesData( columns=[ "time", *[ @@ -396,8 +386,8 @@ def test__get_timeseries_raises_on_mixed_response( records=records, ).model_dump(), ), - PaginatedTimeseriesApiResponse( - data=timeseries_api_models.TimeseriesData( + PaginatedPlatformApiResponse( + data=platform_api_models.TimeseriesData( columns=[], records=[], ).model_dump(), @@ -452,10 +442,10 @@ def test_get_timeseries_raises_api_returned_no_timestamps( ): client = make_client() - with respx_mock_with_base_url(TIMESERIES_API_SUB_PATH) as mock: + with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: mock.get("timeseries").mock( - PaginatedTimeseriesApiResponse( - data=timeseries_api_models.TimeseriesData( + PaginatedPlatformApiResponse( + data=platform_api_models.TimeseriesData( columns=["something but not time"], records=[], ).model_dump() @@ -504,7 +494,7 @@ def test__get_timeseries_raises_on_chunk_value_error( variable=st.builds( user_models.Variable, data_type=st.just("INTEGER"), - machine=st.builds(timeseries_api_models.Machine), + machine=st.builds(platform_api_models.Machine), ), records=st.lists( st.tuples( @@ -525,10 +515,10 @@ def f(*args, **kwargs): monkeypatch.setattr("enlyze.client.reduce", f) - with respx_mock_with_base_url(TIMESERIES_API_SUB_PATH) as mock: + with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: mock.get("timeseries").mock( - PaginatedTimeseriesApiResponse( - data=timeseries_api_models.TimeseriesData( + PaginatedPlatformApiResponse( + data=platform_api_models.TimeseriesData( columns=["time", str(variable.uuid)], records=records, ).model_dump() @@ -545,11 +535,11 @@ def f(*args, **kwargs): st.text(), ), machine=st.builds( - timeseries_api_models.Machine, - site=st.just(SITE_ID), + platform_api_models.Machine, + site=st.just(SITE_UUID_ONE), uuid=st.just(MACHINE_UUID), ), - site=st.builds(timeseries_api_models.Site, id=st.just(SITE_ID)), + site=st.builds(platform_api_models.Site, uuid=st.just(SITE_UUID_ONE)), start=st.one_of(datetime_before_today_strategy, st.none()), end=st.one_of(datetime_today_until_now_strategy, st.none()), production_runs=production_runs_strategy, @@ -569,20 +559,13 @@ def test_get_production_runs( machine_user_model = machine.to_user_model(site_user_model) machines_by_uuid = {machine.uuid: machine_user_model} - with ( - respx_mock_with_base_url(TIMESERIES_API_SUB_PATH) as timeseries_api_mock, - respx_mock_with_base_url( - PRODUCTION_RUNS_API_SUB_PATH - ) as production_runs_api_mock, - ): - timeseries_api_mock.get("appliances").mock( - PaginatedTimeseriesApiResponse(data=[machine]) - ) - timeseries_api_mock.get("sites").mock( - PaginatedTimeseriesApiResponse(data=[site]) + with respx_mock_with_base_url(PLATFORM_API_SUB_PATH) as mock: + mock.get("machines").mock( + PaginatedPlatformApiResponse(data=[machine.model_dump()]) ) - production_runs_api_mock.get("production-runs").mock( - PaginatedProductionRunsApiResponse( + mock.get("sites").mock(PaginatedPlatformApiResponse(data=[site.model_dump()])) + mock.get("production-runs").mock( + PaginatedPlatformApiResponse( data=[p.model_dump(by_alias=True) for p in production_runs] ) ) From 84932dd56af6934df29228814a731ff3aad7ada6 Mon Sep 17 00:00:00 2001 From: Daniel Roussel Date: Tue, 13 May 2025 12:30:22 +0200 Subject: [PATCH 02/10] Getting timeseries endpoitn now uses POST instead of GET --- src/enlyze/api_client/client.py | 116 +++++++++++++++++++++++++++++--- src/enlyze/client.py | 69 +++++++++---------- src/enlyze/constants.py | 4 -- src/enlyze/validators.py | 10 +-- tests/enlyze/test_client.py | 12 ++-- 5 files changed, 148 insertions(+), 63 deletions(-) diff --git a/src/enlyze/api_client/client.py b/src/enlyze/api_client/client.py index a382293..1e3f869 100644 --- a/src/enlyze/api_client/client.py +++ b/src/enlyze/api_client/client.py @@ -1,4 +1,5 @@ import json +from enum import StrEnum, auto 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(StrEnum): + GET = auto() + POST = auto() + + @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,10 +114,42 @@ 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 GET endpoint :param api_path: Relative URL path inside the ENLYZE platform API :param model: API response model class derived from @@ -117,17 +159,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 +202,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/client.py b/src/enlyze/client.py index f3b545d..bd01de6 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]]: +): 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,29 @@ def _get_paginated_timeseries( machine_uuid: str, start: datetime, end: datetime, - variables: Sequence[str], + variables: dict[UUID, Optional[user_models.ResamplingMethod]], + # variables: Sequence[UUID], + # variable_resampling_methods: Optional[Sequence[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 +205,19 @@ 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) + + start, end, machine_uuid = validate_timeseries_arguments(start, end, variables) - start, end, machine_uuid = validate_timeseries_arguments( - start, end, variables_sequence + 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,10 +228,10 @@ def _get_timeseries( machine_uuid=machine_uuid, start=start, end=end, - variables=chunk, + variables=dict(variable_chunk), resampling_interval=resampling_interval, ) - for chunk in chunks + for variable_chunk in chunks ) timeseries_data_chunked = [ @@ -263,7 +258,7 @@ def _get_timeseries( return timeseries_data.to_user_model( # type: ignore start=start, end=end, - variables=variables_sequence, + variables=list(variables), ) 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/validators.py b/src/enlyze/validators.py index 50bb992..a8306dd 100644 --- a/src/enlyze/validators.py +++ b/src/enlyze/validators.py @@ -1,6 +1,6 @@ import logging from datetime import datetime, timezone -from typing import Sequence +from typing import Iterable import enlyze.models as user_models from enlyze.constants import MINIMUM_RESAMPLING_INTERVAL @@ -49,15 +49,15 @@ def validate_start_and_end(start: datetime, end: datetime) -> tuple[datetime, da def validate_timeseries_arguments( start: datetime, end: datetime, - variables: Sequence[user_models.Variable], + variables: Iterable[user_models.Variable], ) -> tuple[datetime, datetime, str]: - if not variables: - raise EnlyzeError("Need to request at least one variable") - start, end = validate_start_and_end(start, end) machine_uuids = frozenset(v.machine.uuid for v in variables) + if not machine_uuids: + raise EnlyzeError("Need to request at least one variable") + if len(machine_uuids) != 1: raise EnlyzeError( "Cannot request timeseries data for more than one machine per request." 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)], From ca63d20a412fc367bbe81125a35df35885ad66d4 Mon Sep 17 00:00:00 2001 From: Daniel Roussel Date: Tue, 13 May 2025 12:30:56 +0200 Subject: [PATCH 03/10] v2 API doesn't return site address anymore --- src/enlyze/api_client/models.py | 2 -- src/enlyze/models.py | 3 --- 2 files changed, 5 deletions(-) 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/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: From 2a93b9ce72ad735f70be2e5e8103d80e0aba4a41 Mon Sep 17 00:00:00 2001 From: Daniel Roussel Date: Tue, 13 May 2025 12:43:13 +0200 Subject: [PATCH 04/10] make mypy happy --- src/enlyze/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/enlyze/client.py b/src/enlyze/client.py index bd01de6..81ce84e 100644 --- a/src/enlyze/client.py +++ b/src/enlyze/client.py @@ -50,7 +50,7 @@ def validate_resampling( Mapping[user_models.Variable, user_models.ResamplingMethod], ], resampling_interval: Optional[int], -): +) -> None: if isinstance(variables, abc.Sequence) and resampling_interval is not None: raise ResamplingValidationError( "`variables` must be a mapping {variable: ResamplingMethod}" From 6cc41bf45a034b96768dd742a91ae1c68ed98bed Mon Sep 17 00:00:00 2001 From: Daniel Roussel Date: Tue, 13 May 2025 12:47:28 +0200 Subject: [PATCH 05/10] no StrEnum in Python<3.11 --- src/enlyze/api_client/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/enlyze/api_client/client.py b/src/enlyze/api_client/client.py index 1e3f869..883a034 100644 --- a/src/enlyze/api_client/client.py +++ b/src/enlyze/api_client/client.py @@ -1,5 +1,5 @@ import json -from enum import StrEnum, auto +from enum import Enum, auto from functools import cache from http import HTTPStatus from typing import Any, Iterator, Type, TypeVar @@ -19,7 +19,7 @@ USER_AGENT_NAME_VERSION_SEPARATOR = "/" -class RequestMethod(StrEnum): +class RequestMethod(str, Enum): GET = auto() POST = auto() From 4c588fd4a6b1dc4abfccb640406883dc4a521722 Mon Sep 17 00:00:00 2001 From: Daniel Roussel Date: Tue, 13 May 2025 13:44:38 +0200 Subject: [PATCH 06/10] `enum.auto()` only generates strings when used with `StrEnum` --- src/enlyze/api_client/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/enlyze/api_client/client.py b/src/enlyze/api_client/client.py index 883a034..ecef970 100644 --- a/src/enlyze/api_client/client.py +++ b/src/enlyze/api_client/client.py @@ -1,5 +1,5 @@ import json -from enum import Enum, auto +from enum import Enum from functools import cache from http import HTTPStatus from typing import Any, Iterator, Type, TypeVar @@ -20,8 +20,8 @@ class RequestMethod(str, Enum): - GET = auto() - POST = auto() + GET = "get" + POST = "post" @cache From 208d8c3643e2b4466dda94cc52a377417af36c52 Mon Sep 17 00:00:00 2001 From: Daniel Roussel Date: Tue, 13 May 2025 15:07:29 +0200 Subject: [PATCH 07/10] remove old comments --- src/enlyze/client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/enlyze/client.py b/src/enlyze/client.py index 81ce84e..ab0bb6e 100644 --- a/src/enlyze/client.py +++ b/src/enlyze/client.py @@ -171,8 +171,6 @@ def _get_paginated_timeseries( start: datetime, end: datetime, variables: dict[UUID, Optional[user_models.ResamplingMethod]], - # variables: Sequence[UUID], - # variable_resampling_methods: Optional[Sequence[user_models.ResamplingMethod]], resampling_interval: Optional[int], ) -> Iterator[platform_api_models.TimeseriesData]: request: dict[str, Any] = { From 9439a893378b5fe09a6f5d544a1707bc0c12ae9e Mon Sep 17 00:00:00 2001 From: Daniel Roussel Date: Tue, 13 May 2025 15:11:08 +0200 Subject: [PATCH 08/10] fix docstring --- src/enlyze/api_client/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/enlyze/api_client/client.py b/src/enlyze/api_client/client.py index ecef970..2de1977 100644 --- a/src/enlyze/api_client/client.py +++ b/src/enlyze/api_client/client.py @@ -149,8 +149,9 @@ def _request_paginated( model: Type[T], **kwargs: Any, ) -> Iterator[T]: - """Retrieve objects from a paginated ENLYZE platform API GET endpoint + """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` From 7d83d7f588fc04619d0b73e1a46fcc11409ee4bc Mon Sep 17 00:00:00 2001 From: Daniel Roussel Date: Tue, 13 May 2025 15:11:15 +0200 Subject: [PATCH 09/10] chunks --- src/enlyze/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/enlyze/client.py b/src/enlyze/client.py index ab0bb6e..4eaa7a7 100644 --- a/src/enlyze/client.py +++ b/src/enlyze/client.py @@ -226,10 +226,10 @@ def _get_timeseries( machine_uuid=machine_uuid, start=start, end=end, - variables=dict(variable_chunk), + variables=dict(chunk), resampling_interval=resampling_interval, ) - for variable_chunk in chunks + for chunk in chunks ) timeseries_data_chunked = [ From 7d3e9f0d18ceb3c71bd50cae912441edf70ab6b5 Mon Sep 17 00:00:00 2001 From: Daniel Roussel Date: Tue, 13 May 2025 15:44:42 +0200 Subject: [PATCH 10/10] go back to sequence --- src/enlyze/client.py | 10 ++++++++-- src/enlyze/validators.py | 10 +++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/enlyze/client.py b/src/enlyze/client.py index 4eaa7a7..dac0202 100644 --- a/src/enlyze/client.py +++ b/src/enlyze/client.py @@ -205,7 +205,13 @@ def _get_timeseries( ) -> Optional[user_models.TimeseriesData]: validate_resampling(variables, resampling_interval) - start, end, machine_uuid = validate_timeseries_arguments(start, end, variables) + variables_list = list(variables) + + start, end, machine_uuid = validate_timeseries_arguments( + start, + end, + variables_list, + ) variable_uuids_with_resampling_method = ( {v.uuid: meth for v, meth in variables.items()} @@ -256,7 +262,7 @@ def _get_timeseries( return timeseries_data.to_user_model( # type: ignore start=start, end=end, - variables=list(variables), + variables=variables_list, ) def get_timeseries( diff --git a/src/enlyze/validators.py b/src/enlyze/validators.py index a8306dd..50bb992 100644 --- a/src/enlyze/validators.py +++ b/src/enlyze/validators.py @@ -1,6 +1,6 @@ import logging from datetime import datetime, timezone -from typing import Iterable +from typing import Sequence import enlyze.models as user_models from enlyze.constants import MINIMUM_RESAMPLING_INTERVAL @@ -49,15 +49,15 @@ def validate_start_and_end(start: datetime, end: datetime) -> tuple[datetime, da def validate_timeseries_arguments( start: datetime, end: datetime, - variables: Iterable[user_models.Variable], + variables: Sequence[user_models.Variable], ) -> tuple[datetime, datetime, str]: + if not variables: + raise EnlyzeError("Need to request at least one variable") + start, end = validate_start_and_end(start, end) machine_uuids = frozenset(v.machine.uuid for v in variables) - if not machine_uuids: - raise EnlyzeError("Need to request at least one variable") - if len(machine_uuids) != 1: raise EnlyzeError( "Cannot request timeseries data for more than one machine per request."