ecodev-core 0.0.67__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ """
2
+ Module implementing a high level Client for calling REST API endpoints
3
+ """
4
+ from datetime import datetime
5
+ from datetime import timezone
6
+ from typing import Optional
7
+
8
+ import requests
9
+ from ecodev_core import logger_get
10
+ from ecodev_core.authentication import ALGO
11
+ from ecodev_core.authentication import SECRET_KEY
12
+ from jose import jwt
13
+ from pydantic import BaseModel
14
+ from ecodev_core.rest_api_configuration import LOGIN_URL
15
+ from ecodev_core.rest_api_configuration import API_USER
16
+ from ecodev_core.rest_api_configuration import API_PASSWORD
17
+
18
+ log = logger_get(__name__)
19
+
20
+
21
+ class RestApiClient(BaseModel):
22
+ """
23
+ Client for making calls to internal REST API endpoints.
24
+
25
+ Attributes:
26
+ timeout (int): HTTP connection timeout 30 (sec).
27
+ _token (dict): Last fetched authentication token.
28
+
29
+ NB:
30
+ - When using this class, tokens should be accessed using the property `token` and \
31
+ not `_token` to enforce token auto-refresh and avoid using expired auth. tokens.
32
+ """
33
+ timeout: int = 30
34
+ _token: dict = {}
35
+
36
+ def _get_new_token(self) -> dict:
37
+ """
38
+ Fetches the authentication token from login API.
39
+
40
+ Raises:
41
+ ConnectionRefusedError: If the request returned None
42
+
43
+ Returns:
44
+ dict: The authentication token response from login API.
45
+ """
46
+ if (data := handle_response(requests.post(f'{LOGIN_URL}',
47
+ data={'username': API_USER,
48
+ 'password': API_PASSWORD}))) is None:
49
+ raise ConnectionRefusedError('Failed to login')
50
+ return data
51
+
52
+ @property
53
+ def token(self) -> dict:
54
+ """
55
+ Returns the authentication token with auto-refresh logic if the
56
+ token is expired or within 1 minute to expiration.
57
+
58
+ Returns:
59
+ token (dict): Dictionary containing Authentication token
60
+ """
61
+ if self.get_exp() < datetime.now(timezone.utc).timestamp() + 60:
62
+ self._token = self._get_new_token()
63
+ return self._token
64
+
65
+ def get_exp(self) -> float:
66
+ """
67
+ Fetch expiration time from existing token `_token`. Defaults to current time.
68
+
69
+ Returns:
70
+ float: Token expiration time.
71
+ """
72
+ try:
73
+ payload = jwt.decode(self._token.get('access_token'), SECRET_KEY, algorithms=[ALGO])
74
+ return payload.get('exp', datetime.now(timezone.utc).timestamp())
75
+ except Exception:
76
+ log.warning('Failed to decode token, exp set to current timestamp')
77
+ return datetime.now(timezone.utc).timestamp()
78
+
79
+ def _get_header(self) -> dict:
80
+ """
81
+ Returns the headers with authorization information for
82
+ HTTP requests to internal REST APIs.
83
+
84
+ Returns:
85
+ dict: HTTP request headers internal REST APIs.
86
+ """
87
+ return {'accept': 'application/json',
88
+ 'Content-Type': 'application/json',
89
+ 'Authorization': f'Bearer {self.token.get("access_token")}'}
90
+
91
+ def get(self,
92
+ url: str,
93
+ params: Optional[dict] = None):
94
+ """
95
+ Attributes:
96
+ url (str): Url of the HTTP request
97
+ params (Optional[dict] = None): Query parameters to add to the url. \
98
+ Defaults to None.
99
+
100
+ Returns:
101
+ response_data (Any): Response body
102
+ """
103
+ return handle_response(requests.get(url=url, headers=self._get_header(),
104
+ timeout=self.timeout, params=params))
105
+
106
+ def post(self,
107
+ url: str,
108
+ data: Optional[dict] = None,
109
+ params: Optional[dict] = None):
110
+ """
111
+ Attributes:
112
+ url (str): Url of the HTTP request
113
+ data (Optional[dict] = None): The body/payload of the request. Defaults to None.
114
+ params (Optional[dict] = None): Query parameters to add to the url. \
115
+ Defaults to None.
116
+
117
+ Returns:
118
+ response_data (Any): Response body
119
+ """
120
+ return handle_response(requests.post(url=url, data=data, headers=self._get_header(),
121
+ timeout=self.timeout, params=params))
122
+
123
+ def put(self,
124
+ url: str,
125
+ data: dict,
126
+ params: Optional[dict] = None):
127
+ """
128
+ Attributes:
129
+ url (str): Url of the HTTP request
130
+ data (Optional[dict] = None): The body/payload of the request. Defaults to None.
131
+ params (Optional[dict]): Requests parameters to add to the url. \
132
+ Defaults to None.
133
+
134
+ Returns:
135
+ response_data (Any): Response body
136
+ """
137
+ return handle_response(requests.put(url=url, data=data, headers=self._get_header(),
138
+ timeout=self.timeout, params=params))
139
+
140
+ def patch(self,
141
+ url: str,
142
+ data: dict,
143
+ params: Optional[dict] = None):
144
+ """
145
+ Attributes:
146
+ url (str): Url of the HTTP request
147
+ data (Optional[dict] = None): The body/payload of the request. Defaults to None.
148
+ params (Optional[dict]): Query parameters to add to the url. \
149
+ Defaults to None.
150
+
151
+ Returns:
152
+ response_data (Any): Response body
153
+ """
154
+ return handle_response(requests.patch(url=url, data=data, headers=self._get_header(),
155
+ timeout=self.timeout, params=params))
156
+
157
+ def delete(self,
158
+ url: str,
159
+ params: Optional[dict] = None):
160
+ """
161
+ Attributes:
162
+ url (str): Url of the HTTP request
163
+ params (Optional[dict]): Query parameters to add to the url. \
164
+ Defaults to None.
165
+
166
+ Returns:
167
+ response_data (Any): Response body
168
+ """
169
+ return handle_response(requests.delete(url=url, headers=self._get_header(),
170
+ timeout=self.timeout, params=params))
171
+
172
+
173
+ def handle_response(response: requests.Response):
174
+ """
175
+ Extracts the data from the http response object
176
+
177
+ Attributes:
178
+ response (requests.Response): HTTP response object to handle
179
+
180
+ Raises:
181
+ HTTPError: If the HTTP request returned an unsuccessful status code.
182
+ Exception: For any other error parsing the response.
183
+
184
+ Returns:
185
+ data (Any): Extracted data from HTTP response object
186
+ """
187
+ try:
188
+ response.raise_for_status()
189
+ response_body = response.json()
190
+ return response_body
191
+ except requests.HTTPError as http_exception:
192
+ log.error(f'Error {response.status_code} : {response.text}')
193
+ raise http_exception
194
+ except Exception as e:
195
+ log.error('Failed to parse response body')
196
+ raise e
197
+
198
+
199
+ REST_API_CLIENT: Optional[RestApiClient] = None
200
+
201
+
202
+ def get_rest_api_client() -> RestApiClient:
203
+ """
204
+ Initiate or return existing RestApiClient instance
205
+ """
206
+ global REST_API_CLIENT
207
+
208
+ if REST_API_CLIENT is None:
209
+ REST_API_CLIENT = RestApiClient()
210
+
211
+ return REST_API_CLIENT
@@ -0,0 +1,25 @@
1
+ """
2
+ Module implementing restapi authentication configuration.
3
+ """
4
+ from pydantic_settings import BaseSettings
5
+ from pydantic_settings import SettingsConfigDict
6
+
7
+ from ecodev_core.settings import SETTINGS
8
+
9
+
10
+ class RestApiConfiguration(BaseSettings):
11
+ """
12
+ Simple authentication configuration class
13
+ """
14
+ host: str = ''
15
+ user: str = ''
16
+ password: str = ''
17
+ model_config = SettingsConfigDict(env_file='.env')
18
+
19
+
20
+ API_AUTH = RestApiConfiguration()
21
+
22
+
23
+ LOGIN_URL = SETTINGS.api.host or API_AUTH.host + '/login'
24
+ API_USER = SETTINGS.api.user or API_AUTH.user
25
+ API_PASSWORD = SETTINGS.api.password or API_AUTH.password
@@ -0,0 +1,241 @@
1
+ """
2
+ Unittest wrapper ensuring safe setUp / tearDown of all tests.
3
+ This boilerplate code is not to be touched under any circumstances.
4
+ """
5
+ import contextlib
6
+ import shutil
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from typing import Callable
11
+ from typing import List
12
+ from typing import Union
13
+ from unittest import TestCase
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ from pydantic import Field
18
+ from sqlalchemy import Engine
19
+ from sqlmodel import create_engine
20
+ from sqlmodel import SQLModel
21
+
22
+ from ecodev_core.db_connection import exec_admin_queries
23
+ from ecodev_core.db_connection import TEST_DB
24
+ from ecodev_core.db_connection import TEST_DB_URL
25
+ from ecodev_core.logger import log_critical
26
+ from ecodev_core.logger import logger_get
27
+ from ecodev_core.pydantic_utils import Frozen
28
+
29
+
30
+ log = logger_get(__name__)
31
+
32
+
33
+ class SafeTestCase(TestCase):
34
+ """
35
+ SafeTestCase makes sure that setUp / tearDown methods are always run when they should be.
36
+ This boilerplate code is not to be touched under any circumstances.
37
+ """
38
+ files_created: List[Path]
39
+ directories_created: List[Path]
40
+ test_engine: Engine
41
+
42
+ @classmethod
43
+ def setUpClass(cls) -> None:
44
+ """
45
+ Class set up, prompt class name and set files and folders to be suppressed at tearDownClass
46
+ """
47
+ log.info(f'Running test module: {cls.__module__.upper()}')
48
+ super().setUpClass()
49
+ cls.directories_created = []
50
+ cls.files_created = []
51
+
52
+ @classmethod
53
+ def tearDownClass(cls) -> None:
54
+ """
55
+ Safely suppress all files and directories used for this class
56
+ """
57
+ log.info(f'Done running test module: {cls.__module__.upper()}')
58
+ cls.safe_delete(cls.directories_created, cls.files_created)
59
+
60
+ def setUp(self) -> None:
61
+ """
62
+ Test set up, prompt test name and set files and folders to be suppressed at tearDown
63
+ """
64
+ super().setUp()
65
+ log.debug(f'Running test: {self._testMethodName.upper()}')
66
+ self.directories_created: List[Path] = []
67
+ self.files_created: List[Path] = []
68
+ exec_admin_queries([f'CREATE DATABASE {TEST_DB}'])
69
+ self.test_engine = create_engine(TEST_DB_URL, pool_pre_ping=True)
70
+ SQLModel.metadata.create_all(self.test_engine)
71
+
72
+ def tearDown(self) -> None:
73
+ """
74
+ Safely suppress all files and directories used for this test
75
+ """
76
+ log.debug(f'Done running test: {self._testMethodName.upper()}')
77
+ self.safe_delete(self.directories_created, self.files_created)
78
+ exec_admin_queries([
79
+ f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{TEST_DB}'",
80
+ f'DROP DATABASE IF EXISTS {TEST_DB}'
81
+ ])
82
+
83
+ @classmethod
84
+ def safe_delete(cls, directories_created: List[Path], files_created: List[Path]) -> None:
85
+ """
86
+ Safely suppress all passed files_created and directories_created
87
+
88
+ Args:
89
+ directories_created: directories used for the test making the call
90
+ files_created: files_created used for the test making the call
91
+ """
92
+ for directory in directories_created:
93
+ with contextlib.suppress(FileNotFoundError):
94
+ shutil.rmtree(directory)
95
+ for file_path in files_created:
96
+ file_path.unlink(missing_ok=True)
97
+
98
+ def run(self, result=None):
99
+ """
100
+ Wrapper around unittest run
101
+ """
102
+ test_method = getattr(self, self._testMethodName)
103
+ wrapped_test = self._cleanup_wrapper(test_method, KeyboardInterrupt)
104
+ setattr(self, self._testMethodName, wrapped_test)
105
+ self.setUp = self._cleanup_wrapper(self.setUp, BaseException)
106
+
107
+ return super().run(result)
108
+
109
+ def _cleanup_wrapper(self, method, exception):
110
+ """
111
+ Boilerplate code for clean setup and teardown
112
+ """
113
+
114
+ def _wrapped(*args, **kwargs):
115
+ try:
116
+ return method(*args, **kwargs)
117
+ except exception:
118
+ self.tearDown()
119
+ self.doCleanups()
120
+ raise
121
+
122
+ return _wrapped
123
+
124
+
125
+ class SimpleReturn(Frozen):
126
+ """
127
+ Simple output for routes not returning anything
128
+ """
129
+ success: bool = Field(..., description=' True if the treatment went well.')
130
+ error: Union[str, None] = Field(..., description='the error that happened, if any.')
131
+
132
+ @classmethod
133
+ def route_success(cls) -> 'SimpleReturn':
134
+ """
135
+ Format DropDocumentReturn if the document was successfully dropped
136
+ """
137
+ return SimpleReturn(success=True, error=None)
138
+
139
+ @classmethod
140
+ def route_failure(cls, error: str) -> 'SimpleReturn':
141
+ """
142
+ Format DropDocumentReturn if the document failed to be dropped
143
+ """
144
+ return SimpleReturn(success=False, error=error)
145
+
146
+
147
+ def safe_clt(func):
148
+ """
149
+ Safe execution of typer commands
150
+ """
151
+ def inner_function(*args, **kwargs):
152
+ try:
153
+ func(*args, **kwargs)
154
+ return SimpleReturn.route_success()
155
+ except Exception as error:
156
+ log_critical(f'something wrong happened: {error}', log)
157
+ return SimpleReturn.route_failure(str(error))
158
+ return inner_function
159
+
160
+
161
+ def safe_method(func: Callable) -> Any | None:
162
+ """
163
+ Safe execution of a method
164
+ """
165
+ def inner_function(*args, **kwargs):
166
+ try:
167
+ return func(*args, **kwargs)
168
+ except Exception:
169
+ return None
170
+ return inner_function
171
+
172
+
173
+ def stringify(x: Union[str, float], default: str | None = None) -> Union[str, None]:
174
+ """
175
+ Safe conversion of a (str, np.nan) value into a (str,None) one
176
+ """
177
+ return _transformify(x, str, default)
178
+
179
+
180
+ def boolify(x: Union[Any], default: bool | None = None) -> Union[bool, None]:
181
+ """
182
+ Safe conversion of a (str, np.nan) value into a (str,None) one
183
+ """
184
+ return _transformify(x, _bool_check, default)
185
+
186
+
187
+ def intify(x: Union[str, float], default: int | None = None) -> Union[int, None]:
188
+ """
189
+ Safe conversion of a (int, np.nan) value into a (int,None) one
190
+ """
191
+ return _transformify(x, int, default)
192
+
193
+
194
+ def floatify(x: Union[str, float], default: float | None = None) -> Union[float, None]:
195
+ """
196
+ Safe conversion of a (float, np.nan) value into a (float,None) one
197
+ """
198
+ return _transformify(x, float, default)
199
+
200
+
201
+ def datify(date: datetime | str,
202
+ date_format: str,
203
+ default: datetime | None = None
204
+ ) -> Union[datetime, None]:
205
+ """
206
+ Safe conversion to a date format
207
+ """
208
+ if pd.isnull(date):
209
+ return None
210
+ if isinstance(date, datetime):
211
+ return date
212
+ return _transformify(date, lambda x: datetime.strptime(x, date_format), default)
213
+
214
+
215
+ def _transformify(x: Union[Any, float],
216
+ transformation: Callable,
217
+ default: Any | None) -> Union[Any, None]:
218
+ """
219
+ Safe conversion of a (Any, np.nan) value into a (Any,None) one thanks to transformation
220
+ """
221
+ if x is None or (isinstance(x, float) and np.isnan(x)):
222
+ return default
223
+
224
+ try:
225
+ return transformed if (transformed := transformation(x)) is not None else default
226
+
227
+ except ValueError:
228
+ return default
229
+
230
+
231
+ def _bool_check(x: Union[Any, float]):
232
+ """
233
+ Check if the passed element can be cast into a boolean, or the boolean value inferred.
234
+ """
235
+ if isinstance(x, bool):
236
+ return x
237
+
238
+ if not isinstance(x, str) or x.lower() not in ['true', 'yes', 'false', 'no']:
239
+ return None
240
+
241
+ return x.lower() in ['true', 'yes']
@@ -0,0 +1,51 @@
1
+ """
2
+ Module defining a dynamic setting class
3
+ """
4
+ from contextlib import suppress
5
+ from pathlib import Path
6
+
7
+ from pydantic.v1.utils import deep_update
8
+ from pydantic_settings import BaseSettings
9
+ from pydantic_settings import SettingsConfigDict
10
+
11
+ from ecodev_core.deployment import Deployment
12
+ from ecodev_core.list_utils import dict_to_class
13
+ from ecodev_core.read_write import load_yaml_file
14
+
15
+
16
+ class DeploymentSetting(BaseSettings):
17
+ """
18
+ Settings class used to load the deployment type from environment variables.
19
+ """
20
+ environment: str = 'local'
21
+ base_path: str = '/app'
22
+ model_config = SettingsConfigDict(env_file='.env')
23
+
24
+
25
+ DEPLOYMENT_SETTINGS = DeploymentSetting()
26
+ DEPLOYMENT = Deployment(DEPLOYMENT_SETTINGS.environment.lower())
27
+ BASE_PATH = Path(DEPLOYMENT_SETTINGS.base_path)
28
+
29
+
30
+ class Settings:
31
+ """
32
+ Dynami setting class, loading yaml configuration from config file, possibly overwriting some of
33
+ this configuration with additional information coming from a secret file.
34
+ """
35
+
36
+ def __init__(self, base_path: Path = BASE_PATH, deployment: Deployment = DEPLOYMENT):
37
+ """
38
+ Dynamically setting Settings attributes, doing so recursively. Attributes are loaded
39
+ from config file, possibly overwriting some of this configuration with additional
40
+ information coming from a secret file.
41
+ """
42
+ self.deployment = deployment
43
+ with suppress(FileNotFoundError):
44
+ data = load_yaml_file(base_path / 'config' / f'{deployment.value}.yaml')
45
+ if (secrets_file := base_path / 'secrets' / f'{deployment.value}.yaml').exists():
46
+ data = deep_update(data, load_yaml_file(secrets_file))
47
+ for k, v in dict_to_class(data).items():
48
+ setattr(self, k, v)
49
+
50
+
51
+ SETTINGS = Settings()
@@ -0,0 +1,16 @@
1
+ """
2
+ module implementing SQLModel related helper methods. Related to validation
3
+ """
4
+ from sqlmodel import SQLModel
5
+
6
+
7
+ class SQLModelWithVal(SQLModel):
8
+ """
9
+ Helper class to ease validation in SQLModel classes with table=True
10
+ """
11
+ @classmethod
12
+ def create(cls, **kwargs):
13
+ """
14
+ Forces validation to take place, even for SQLModel classes with table=True
15
+ """
16
+ return cls(**cls.__bases__[0](**kwargs).model_dump())
@@ -0,0 +1,18 @@
1
+ """
2
+ Module implementing the token ban list table
3
+ """
4
+ from datetime import datetime
5
+ from typing import Optional
6
+
7
+ from sqlmodel import Field
8
+ from sqlmodel import SQLModel
9
+
10
+
11
+ class TokenBanlist(SQLModel, table=True): # type: ignore
12
+ """
13
+ A token banlist: timestamped banned token.
14
+ """
15
+ __tablename__ = 'token_banlist'
16
+ id: Optional[int] = Field(default=None, primary_key=True)
17
+ created_at: datetime = Field(default_factory=datetime.utcnow)
18
+ token: str = Field(index=True)