diff --git a/src/delta_barth/analysis/forecast.py b/src/delta_barth/analysis/forecast.py index 15fb198..155d352 100644 --- a/src/delta_barth/analysis/forecast.py +++ b/src/delta_barth/analysis/forecast.py @@ -10,7 +10,7 @@ from xgboost import XGBRegressor from delta_barth.analysis import parse from delta_barth.constants import COL_MAP_SALES_PROGNOSIS, FEATURES_SALES_PROGNOSIS -from delta_barth.types import CustomerDataSalesForecast, FcErrorCodes +from delta_barth.types import CustomerDataSalesForecast, DataPipelineErrors if TYPE_CHECKING: from delta_barth.api.common import SalesPrognosisResponse @@ -105,7 +105,7 @@ def sales_per_customer( # check data availability if len(df_cust) < min_num_data_points: - return FcErrorCodes.DATA_TOO_FEW_POINTS, None + return DataPipelineErrors.DATA_TOO_FEW_POINTS, None else: # Entwicklung der Umsätze: definierte Zeiträume Monat df_cust["year"] = df_cust["date"].dt.year @@ -144,4 +144,4 @@ def sales_per_customer( test = test.reset_index(drop=True) # umsetzung, prognose - return FcErrorCodes.SUCCESS, test + return DataPipelineErrors.SUCCESS, test diff --git a/src/delta_barth/api/common.py b/src/delta_barth/api/common.py index a1076ad..dbb5b06 100644 --- a/src/delta_barth/api/common.py +++ b/src/delta_barth/api/common.py @@ -5,10 +5,10 @@ from datetime import datetime as Datetime from typing import Final, Never import requests -from pydantic import BaseModel, PositiveInt +from pydantic import BaseModel, PositiveInt, SkipValidation from requests import Response -from delta_barth.constants import HTTP_CURRENT_CONNECTION, KnownApiErrorCodes +from delta_barth.constants import HTTP_CURRENT_CONNECTION, KnownDelBarApiErrorCodes from delta_barth.errors import ( ApiConnectionError, UnknownApiErrorCode, @@ -113,7 +113,7 @@ def login( if resp.status_code == 200: response = LoginResponse(**resp.json()) HTTP_CURRENT_CONNECTION.add_session_token(response.token) - elif resp.status_code in KnownApiErrorCodes.COMMON.value: + elif resp.status_code in KnownDelBarApiErrorCodes.COMMON.value: err = DelBarApiError(status_code=resp.status_code, **resp.json()) response = LoginResponse(token="", error=err) else: # pragma: no cover @@ -142,7 +142,7 @@ def logout( if resp.status_code == 200: response = LogoutResponse() HTTP_CURRENT_CONNECTION.remove_session_token() - elif resp.status_code in KnownApiErrorCodes.COMMON.value: + elif resp.status_code in KnownDelBarApiErrorCodes.COMMON.value: err = DelBarApiError(status_code=resp.status_code, **resp.json()) response = LogoutResponse(error=err) else: # pragma: no cover @@ -153,8 +153,8 @@ def logout( # ** sales data class SalesPrognosisRequestP(BaseModel): - FirmaId: int | None - BuchungsDatum: Datetime | None + FirmaId: SkipValidation[int | None] + BuchungsDatum: SkipValidation[Datetime | None] class SalesPrognosisResponseEntry(BaseModel): @@ -192,7 +192,7 @@ def get_sales_prognosis_data( response: SalesPrognosisResponse if resp.status_code == 200: response = SalesPrognosisResponse(**resp.json()) - elif resp.status_code in KnownApiErrorCodes.COMMON.value: # pragma: no cover + elif resp.status_code in KnownDelBarApiErrorCodes.COMMON.value: # pragma: no cover err = DelBarApiError(status_code=resp.status_code, **resp.json()) response = SalesPrognosisResponse(daten=tuple(), error=err) else: # pragma: no cover diff --git a/src/delta_barth/constants.py b/src/delta_barth/constants.py index 43b25cc..408bcce 100644 --- a/src/delta_barth/constants.py +++ b/src/delta_barth/constants.py @@ -3,7 +3,9 @@ from typing import Final from delta_barth.types import CurrentConnection, HttpContentHeaders -# ** API connection management +# ** error handling +DEFAULT_INTERNAL_ERR_CODE: Final[int] = 100 + HTTP_BASE_CONTENT_HEADERS: Final[HttpContentHeaders] = { "Content-type": "application/json", "Accept": "application/json", @@ -14,7 +16,7 @@ HTTP_CURRENT_CONNECTION: Final[CurrentConnection] = CurrentConnection( ) -class KnownApiErrorCodes(enum.Enum): +class KnownDelBarApiErrorCodes(enum.Enum): COMMON = frozenset((400, 401, 409, 500)) diff --git a/src/delta_barth/errors.py b/src/delta_barth/errors.py index 430a192..0a1e5b5 100644 --- a/src/delta_barth/errors.py +++ b/src/delta_barth/errors.py @@ -1,3 +1,14 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from delta_barth.constants import DEFAULT_INTERNAL_ERR_CODE +from delta_barth.types import DataPipelineErrors, doptResponseError + +if TYPE_CHECKING: + from delta_barth.types import ErrorDescription + + class UnspecifiedRequestType(Exception): """exception raised if for a given API endpoint a not defined operation is requested""" @@ -12,3 +23,45 @@ class ApiConnectionError(Exception): class FeaturesMissingError(Exception): """exception raised if needed features are missing""" + + +## ** internal error handling +DATA_PIPELINE_ERRORS_DESCR: Final[tuple[ErrorDescription, ...]] = ( + ("SUCCESS", 0, "Erfolg"), + ("TOO_FEW_POINTS", 1, "Datensatz besitzt nicht genügend Datenpunkte"), + ("BAD_QUALITY", 2, "Prognosequalität des Modells unzureichend"), +) + + +class ErrorManager: + def __init__(self) -> None: + self._data_pipelines: DataPipelineErrors | None = None + self._parse_data_pipeline_errors() + + @property + def data_pipelines(self) -> DataPipelineErrors: + assert self._data_pipelines is not None, ( + "tried to access not parsed data pipeline errors" + ) + return self._data_pipelines + + def _parse_data_pipeline_errors(self) -> None: + if self._data_pipelines is not None: + return + parsed_errors: dict[str, doptResponseError] = {} + for err in DATA_PIPELINE_ERRORS_DESCR: + parsed_errors[err[0]] = doptResponseError(status_code=err[1], description=err[2]) + + self._data_pipelines = DataPipelineErrors(**parsed_errors) + + def internal_error( + self, + description: str, + message: str = "", + err_code: int = DEFAULT_INTERNAL_ERR_CODE, + ) -> doptResponseError: + return doptResponseError( + status_code=err_code, + description=description, + message=message, + ) diff --git a/src/delta_barth/types.py b/src/delta_barth/types.py index 334a774..719328a 100644 --- a/src/delta_barth/types.py +++ b/src/delta_barth/types.py @@ -4,9 +4,25 @@ from dataclasses import dataclass, field from typing import NotRequired, TypeAlias, TypedDict import pandas as pd - +from pydantic import BaseModel, SkipValidation # ** API +ErrorDescription: TypeAlias = tuple[str, int, str] + + +class doptResponseError(BaseModel): + status_code: SkipValidation[int] + description: SkipValidation[str] + message: SkipValidation[str] = "" + + +@dataclass(slots=True) +class DataPipelineErrors: + SUCCESS: doptResponseError + TOO_FEW_POINTS: doptResponseError + BAD_QUALITY: doptResponseError + + class HttpRequestTypes(enum.StrEnum): GET = enum.auto() POST = enum.auto() @@ -73,10 +89,4 @@ class CustomerDataSalesForecast: sales: list[float] = field(default_factory=list) -class FcErrorCodes(enum.IntEnum): - SUCCESS = 0 - DATA_TOO_FEW_POINTS = 1 - DATA_BAD_QUALITY = 2 - - -FcResult: TypeAlias = tuple[FcErrorCodes, pd.DataFrame | None] +FcResult: TypeAlias = tuple[DataPipelineErrors, pd.DataFrame | None] diff --git a/tests/api/test_common.py b/tests/api/test_common.py index db3b4ab..9bffcde 100644 --- a/tests/api/test_common.py +++ b/tests/api/test_common.py @@ -105,8 +105,7 @@ def test_login_logout(credentials, api_base_url): assert resp.error.message == "Nutzer oder Passwort falsch." -# @pytest.mark.api_con_required -@pytest.mark.new +@pytest.mark.api_con_required def test_get_sales_prognosis_data(credentials, api_base_url): resp = common.login( base_url=api_base_url, diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..d27ebf5 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import asdict +from typing import Any, cast + +import pytest + +from delta_barth import errors +from delta_barth.types import doptResponseError + + +def test_error_manager_parsing(): + predef_errs = errors.DATA_PIPELINE_ERRORS_DESCR + + err_mgr = errors.ErrorManager() + assert err_mgr.data_pipelines is not None + parsed_pipe_errs = err_mgr.data_pipelines + parsed_pipe_errs = asdict(parsed_pipe_errs) + + for err in predef_errs: + dopt_err = cast(doptResponseError, parsed_pipe_errs[err[0]]) + assert isinstance(dopt_err, doptResponseError) + assert dopt_err.status_code == err[1] + assert dopt_err.description == err[2] + assert dopt_err.message == "" + + err_mgr._parse_data_pipeline_errors() + + +def test_error_manager_internal(): + DESCRIPTION = "test case" + MESSAGE = "an error occurred" + ERR_CODE = 101 + + err_mgr = errors.ErrorManager() + new_err = err_mgr.internal_error( + description=DESCRIPTION, + message=MESSAGE, + err_code=ERR_CODE, + ) + assert new_err.status_code == ERR_CODE + assert new_err.description == DESCRIPTION + assert new_err.message == MESSAGE